From c15df8fa9bbeb59f89f847047add35e2bcbd1f53 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:20:52 +1100 Subject: [PATCH 01/14] Bring back v26 changes to main (#18) * Fix return types and color band range (#5) * fix types and remove non-existing function * fix tests * fix color band range * increment patch number * fix range and tests * changelog * bring back function * more tests * missing include * typo * better docstrings * correctness * ws * point to pypi (#3) --- .github/workflows/publish.yml | 6 ++-- CHANGELOG.md | 36 +++++++++++++------ src/moldflow/constants.py | 2 +- src/moldflow/double_array.py | 7 ++-- src/moldflow/integer_array.py | 7 ++-- src/moldflow/plot.py | 15 ++++---- src/moldflow/string_array.py | 8 +++-- .../api/unit_tests/test_unit_double_array.py | 6 +++- .../api/unit_tests/test_unit_integer_array.py | 5 ++- tests/api/unit_tests/test_unit_plot.py | 2 +- .../api/unit_tests/test_unit_string_array.py | 5 ++- version.json | 2 +- 12 files changed, 66 insertions(+), 35 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c07ad12..ad6cd11 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,7 +84,7 @@ jobs: $packageName = 'moldflow' try { - $resp = Invoke-WebRequest -Uri "https://test.pypi.org/pypi/$packageName/json" -UseBasicParsing -ErrorAction Stop + $resp = Invoke-WebRequest -Uri "https://pypi.org/pypi/$packageName/json" -UseBasicParsing -ErrorAction Stop $data = $resp.Content | ConvertFrom-Json $versions = @($data.releases.PSObject.Properties.Name) } catch { @@ -118,9 +118,9 @@ jobs: if: steps.pypi_check.outputs.exists == 'false' env: TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | - python run.py publish --skip-build --testpypi + python run.py publish --skip-build - name: Create GitHub Release if: steps.pypi_check.outputs.exists == 'false' diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cb0c1..fcf9136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Initial public release -- Python API wrapper for Moldflow Synergy -- Comprehensive test suite -- Documentation with examples -- CI/CD pipeline for automated testing and publishing +- N/A ### Changed - N/A @@ -29,13 +25,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - N/A -## [26.0.0] - 2025-XX-XX +## [26.0.1] - 2025-09-12 + +### Added +- N/A + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- Fix return types for `from_list` functions in data classes +- Fix color band range options to 1 to 256 + +### Security +- N/A + +## [26.0.0] - 2025-09-01 ### Added -- Initial version aligned with Moldflow Synergy 2026 -- Basic API functionality -- Windows support +- Initial version aligned with Moldflow Synergy 2026.0.1 - Python 3.10-3.13 compatibility -[Unreleased]: https://github.com/Autodesk/moldflow-api/compare/v26.0.0...HEAD +[Unreleased]: https://github.com/Autodesk/moldflow-api/compare/v26.0.1...HEAD +[26.0.1]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.1 [26.0.0]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.0 diff --git a/src/moldflow/constants.py b/src/moldflow/constants.py index c785166..4851f3a 100644 --- a/src/moldflow/constants.py +++ b/src/moldflow/constants.py @@ -7,7 +7,7 @@ import os # Constants for color bands -COLOR_BAND_RANGE = tuple(list(range(2, 65)) + [256]) +COLOR_BAND_RANGE = tuple(range(1, 257)) # Localization constants DEFAULT_THREE_LETTER_CODE = "enu" diff --git a/src/moldflow/double_array.py b/src/moldflow/double_array.py index e2d16c5..be3cd28 100644 --- a/src/moldflow/double_array.py +++ b/src/moldflow/double_array.py @@ -67,12 +67,15 @@ def to_list(self) -> list[float]: vb_array = self.double_array.ToVBSArray() return list(vb_array) - def from_list(self, values: list[float]) -> None: + def from_list(self, values: list[float]) -> int: """ Convert a list of floats to a double array. Args: values (list[float]): The list of floats to convert. + + Returns: + int: The number of elements added to the array. """ process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="from_list") @@ -80,7 +83,7 @@ def from_list(self, values: list[float]) -> None: for value in values: check_type(value, (int, float)) - self.double_array.FromVBSArray(list(values)) + return self.double_array.FromVBSArray(list(values)) @property def size(self) -> int: diff --git a/src/moldflow/integer_array.py b/src/moldflow/integer_array.py index f28221c..ab4c4a4 100644 --- a/src/moldflow/integer_array.py +++ b/src/moldflow/integer_array.py @@ -67,12 +67,15 @@ def to_list(self) -> list[int]: vb_array = self.integer_array.ToVBSArray() return list(vb_array) - def from_list(self, values: list[int]) -> None: + def from_list(self, values: list[int]) -> int: """ Convert a list of integers to an integer array. Args: values (list[int]): The list of integers to convert. + + Returns: + int: The number of elements added to the array. """ process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="from_list") @@ -80,7 +83,7 @@ def from_list(self, values: list[int]) -> None: for value in values: check_type(value, int) - self.integer_array.FromVBSArray(list(values)) + return self.integer_array.FromVBSArray(list(values)) @property def size(self) -> int: diff --git a/src/moldflow/plot.py b/src/moldflow/plot.py index 1baeb13..0cdbf24 100644 --- a/src/moldflow/plot.py +++ b/src/moldflow/plot.py @@ -456,13 +456,11 @@ def extended_color(self, value: bool) -> None: @property def color_bands(self) -> int: """ - The number of color bands or smooth coloring. - - values between 2 through 64: banded coloring with this number of colors - - 256: smooth coloring + The number of color bands. + - values between 1 through 256: banded coloring with this number of colors - - :getter: Get the number of color bands or smooth coloring. - :setter: Set the number of color bands or smooth coloring. + :getter: Get the number of color bands. + :setter: Set the number of color bands. :type: int """ process_log(__name__, LogMessage.PROPERTY_GET, locals(), name="color_bands") @@ -471,9 +469,8 @@ def color_bands(self) -> int: @color_bands.setter def color_bands(self, value: int) -> None: """ - The number of color bands or smooth coloring. - - values between 2 through 64: banded coloring with this number of colors - - 256: smooth coloring + The number of color bands. + - values between 1 through 256: banded coloring with this number of colors Args: value (int): number of color bands or smooth coloring to set. diff --git a/src/moldflow/string_array.py b/src/moldflow/string_array.py index ca18c30..dcb953d 100644 --- a/src/moldflow/string_array.py +++ b/src/moldflow/string_array.py @@ -57,19 +57,21 @@ def add_string(self, value: str) -> None: def to_list(self) -> list[str]: """ Convert the string array to a list of strings. - Returns: list[str]: The list of strings. """ process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="to_list") return _mf_array_to_list(self) - def from_list(self, values: list[str]) -> None: + def from_list(self, values: list[str]) -> int: """ Convert a list of strings to a string array. Args: values (list[str]): The list of strings to convert. + + Returns: + int: The number of elements added to the array. """ process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="from_list") check_type(values, (list, tuple)) @@ -77,7 +79,7 @@ def from_list(self, values: list[str]) -> None: for value in values: check_type(value, str) - self.string_array.FromVBSArray(list(values)) + return self.string_array.FromVBSArray(list(values)) @property def size(self) -> int: diff --git a/tests/api/unit_tests/test_unit_double_array.py b/tests/api/unit_tests/test_unit_double_array.py index d19a2e6..37d913b 100644 --- a/tests/api/unit_tests/test_unit_double_array.py +++ b/tests/api/unit_tests/test_unit_double_array.py @@ -72,7 +72,11 @@ def test_to_list(self, mock_double_array, mock_object, values): # pylint: disable=R0801 def test_from_list(self, mock_double_array, mock_object, values): """Test the from_list method of the DoubleArray class.""" - mock_double_array.from_list(values) + mock_object.FromVBSArray.return_value = len(values) + result = mock_double_array.from_list(values) + + assert isinstance(result, int) + assert result == len(values) mock_object.FromVBSArray.assert_called_once_with(list(values)) @pytest.mark.parametrize("invalid_values", INVALID_MOCK_WITH_NONE) diff --git a/tests/api/unit_tests/test_unit_integer_array.py b/tests/api/unit_tests/test_unit_integer_array.py index a6b838a..05a8aa8 100644 --- a/tests/api/unit_tests/test_unit_integer_array.py +++ b/tests/api/unit_tests/test_unit_integer_array.py @@ -65,8 +65,11 @@ def test_to_list(self, mock_integer_array, mock_object, values): # pylint: disable=R0801 def test_from_list(self, mock_integer_array, mock_object, values): """Test the from_list method of the IntegerArray class.""" - mock_integer_array.from_list(values) + mock_object.FromVBSArray.return_value = len(values) + result = mock_integer_array.from_list(values) + assert isinstance(result, int) + assert result == len(values) mock_object.FromVBSArray.assert_called_once_with(list(values)) @pytest.mark.parametrize("invalid_values", INVALID_MOCK_WITH_NONE) diff --git a/tests/api/unit_tests/test_unit_plot.py b/tests/api/unit_tests/test_unit_plot.py index 81870c1..1ffe6c1 100644 --- a/tests/api/unit_tests/test_unit_plot.py +++ b/tests/api/unit_tests/test_unit_plot.py @@ -441,7 +441,7 @@ def test_invalid_properties( @pytest.mark.parametrize( "pascal_name, property_name, value", [("SetMeshFill", "mesh_fill", x) for x in [-1.0, 2.0, 3.0]] - + [("SetColorBands", "color_bands", x) for x in [1, 65, 128]] + + [("SetColorBands", "color_bands", x) for x in [-1, 0, 257, 300]] + [("SetHistogramNumberOfBars", "histogram_number_of_bars", x) for x in [-1, -2, -3, -4]], ) # pylint: disable-next=R0913, R0917 diff --git a/tests/api/unit_tests/test_unit_string_array.py b/tests/api/unit_tests/test_unit_string_array.py index 05fe8de..fca95fa 100644 --- a/tests/api/unit_tests/test_unit_string_array.py +++ b/tests/api/unit_tests/test_unit_string_array.py @@ -82,8 +82,11 @@ def test_to_list(self, mock_string_array, mock_object, size, values): # pylint: disable=R0801 def test_from_list(self, mock_string_array, mock_object, values): """Test the from_list method of the StringArray class.""" - mock_string_array.from_list(values) + mock_object.FromVBSArray.return_value = len(values) + result = mock_string_array.from_list(values) + assert isinstance(result, int) + assert result == len(values) mock_object.FromVBSArray.assert_called_once() @pytest.mark.parametrize("invalid_values", INVALID_MOCK_WITH_NONE) diff --git a/version.json b/version.json index 38caf99..9e2172b 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "major": "26", "minor": "0", - "patch": "0" + "patch": "1" } From 78473aa0c571253b00905d9140425b96aaa6208f Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:51:43 +1100 Subject: [PATCH 02/14] Add convenience message boxes and input dialogs via Win32 (#15) * progress * order * allow ok for localisation * add integration test * format * lint * fix test * working * lint * i18n * format * lint * fnish lint * review * placement * omit test coverage for win32 ui heavy class * add to changelog * fix coverage * more friendly win32 error handling * Update src/moldflow/message_box.py Co-authored-by: Sankalp Shrivastava --------- Co-authored-by: Sankalp Shrivastava --- .coverage-config | 6 + CHANGELOG.md | 2 +- .../source/components/wrapper/message_box.rst | 139 ++ scripts/check_localization.py | 13 +- src/moldflow/__init__.py | 12 +- .../locale/de-DE/LC_MESSAGES/locale.de-DE.po | 9 + .../locale/en-US/LC_MESSAGES/locale.en-US.po | 9 + .../locale/es-ES/LC_MESSAGES/locale.es-ES.po | 9 + .../locale/fr-FR/LC_MESSAGES/locale.fr-FR.po | 9 + .../locale/it-IT/LC_MESSAGES/locale.it-IT.po | 9 + .../locale/ja-JP/LC_MESSAGES/locale.ja-JP.po | 9 + .../locale/ko-KR/LC_MESSAGES/locale.ko-KR.po | 9 + .../locale/pt-PT/LC_MESSAGES/locale.pt-PT.po | 9 + .../locale/zh-CN/LC_MESSAGES/locale.zh-CN.po | 9 + .../locale/zh-TW/LC_MESSAGES/locale.zh-TW.po | 9 + src/moldflow/message_box.py | 1378 +++++++++++++++++ .../test_message_box_permutations.py | 196 +++ 17 files changed, 1832 insertions(+), 4 deletions(-) create mode 100644 docs/source/components/wrapper/message_box.rst create mode 100644 src/moldflow/message_box.py create mode 100644 tests/api/integration_tests/test_message_box_permutations.py diff --git a/.coverage-config b/.coverage-config index 290b639..742b67f 100644 --- a/.coverage-config +++ b/.coverage-config @@ -2,6 +2,9 @@ branch = True source_pkgs = moldflow +omit = + src/moldflow/message_box.py + */site-packages/moldflow/message_box.py [paths] source = @@ -12,6 +15,9 @@ source = fail_under = 93 show_missing = True precision = 2 +omit = + src/moldflow/message_box.py + */site-packages/moldflow/message_box.py [html] title = Moldflow API Unit Test Coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf9136..6fee691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- N/A +- Added convenience class for showing message boxes and text input dialogs via Win32 ### Changed - N/A diff --git a/docs/source/components/wrapper/message_box.rst b/docs/source/components/wrapper/message_box.rst new file mode 100644 index 0000000..6fbe4b4 --- /dev/null +++ b/docs/source/components/wrapper/message_box.rst @@ -0,0 +1,139 @@ +MessageBox +========== + +Convenience wrapper to display message boxes and a simple +text input dialog from Python scripts using the ``moldflow`` package. + +Usage +----- + +.. code-block:: python + + from moldflow import ( + MessageBox, + MessageBoxType, + MessageBoxResult, + MessageBoxOptions, + MessageBoxIcon, + MessageBoxDefaultButton, + MessageBoxModality, + ) + + # Informational message + MessageBox("Operation completed.", MessageBoxType.INFO).show() + + # Confirmation + result = MessageBox("Proceed with analysis?", MessageBoxType.YES_NO).show() + if result == MessageBoxResult.YES: + pass + + # Text input + material_id = MessageBox("Enter your material ID:", MessageBoxType.INPUT).show() + if material_id: + pass + + # Advanced options + opts = MessageBoxOptions( + icon=MessageBoxIcon.WARNING, + default_button=MessageBoxDefaultButton.BUTTON2, + modality=MessageBoxModality.TASK, + topmost=True, + right_align=False, + rtl_reading=False, + help_button=False, + set_foreground=True, + owner_hwnd=None, + ) + result = MessageBox( + "Retry failed operation?", + MessageBoxType.RETRY_CANCEL, + title="Moldflow", + options=opts, + ).show() + +Convenience methods +------------------- + +.. code-block:: python + + MessageBox.info("Saved") + MessageBox.warning("Low disk space") + MessageBox.error("Failed to save") + if MessageBox.confirm_yes_no("Proceed?") == MessageBoxResult.YES: + pass + + # Prompt text with validation + def is_nonempty(s: str) -> bool: + return bool(s.strip()) + + value = MessageBox.prompt_text( + "Enter ID:", + default_text="", + placeholder="e.g. MAT-123", + validator=is_nonempty, + ) + if value is not None: + pass + +Options +------- + +.. list-table:: MessageBoxOptions + :header-rows: 1 + + * - Parameter + - Type + - Description + * - icon + - MessageBoxIcon | None + - Override default icon + * - default_button + - MessageBoxDefaultButton | None + - Set default button (2/3/4). Validated vs type + * - modality + - MessageBoxModality | None + - Application (default), Task-modal, System-modal + * - topmost + - bool + - Keep message box on top + * - set_foreground + - bool + - Force foreground + * - right_align / rtl_reading + - bool + - Layout flags for right-to-left locales + * - help_button + - bool + - Show Help button + * - owner_hwnd + - int | None + - Owner window handle (improves modality/Z-order) + * - default_text / placeholder + - str | None + - Prefill text and cue banner for input dialog + * - is_password + - bool + - Mask input characters + * - char_limit + - int | None + - Maximum characters accepted (client-side) + * - width_dlu / height_dlu + - int | None + - Size the input dialog (dialog units) + * - validator + - Callable[[str], bool] | None + - Enable OK only when input satisfies predicate + * - font_face / font_size_pt + - str / int + - Font for input dialog (default Segoe UI 9pt) + +API +--- + +.. automodule:: moldflow.message_box + +Notes +----- + +- Localization: button captions ("OK", "Cancel"), title, and prompt are localized via the package i18n system. +- Return type: ``MessageBox.show()`` returns ``MessageBoxReturn`` (``MessageBoxResult | str | None``). diff --git a/scripts/check_localization.py b/scripts/check_localization.py index c0be77b..18eab31 100644 --- a/scripts/check_localization.py +++ b/scripts/check_localization.py @@ -38,6 +38,11 @@ from typing import Dict, List, Tuple +# Strings that are acceptable to remain identical across locales +ALLOW_EQUAL_MSGSTR: set[str] = { + "OK", +} + @dataclass class PoEntry: msgid: str @@ -357,9 +362,13 @@ def check_translation_gaps(self) -> List[str]: if not po_parser.has_string(msgid): missing_translations.append(msgid) else: - # Check if translation is empty or same as source (untranslated) + # Check if translation is empty or same as source (untranslated), + # with an allowlist for locale-invariant tokens like "OK" target_entry = po_parser.entries[msgid] - if not target_entry.msgstr.strip() or target_entry.msgstr == msgid: + if ( + not target_entry.msgstr.strip() + or (target_entry.msgstr == msgid and msgid not in ALLOW_EQUAL_MSGSTR) + ): empty_translations.append(msgid) locale_stats[locale_name] = { diff --git a/src/moldflow/__init__.py b/src/moldflow/__init__.py index d875960..0a8f1cc 100644 --- a/src/moldflow/__init__.py +++ b/src/moldflow/__init__.py @@ -102,10 +102,20 @@ from .common import ViewModes from .common import UserPlotType +from .message_box import ( + MessageBox, + MessageBoxType, + MessageBoxResult, + MessageBoxOptions, + MessageBoxIcon, + MessageBoxModality, + MessageBoxDefaultButton, + MessageBoxReturn, +) + # Version checking and update functionality from .version_check import get_version, check_for_updates_on_import - # Check for updates on import unless disabled check_for_updates_on_import() diff --git a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po index 4203e8c..3828e8a 100644 --- a/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po +++ b/src/moldflow/locale/de-DE/LC_MESSAGES/locale.de-DE.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: de-DE\n" +msgid "Cancel" +msgstr "Abbrechen" + msgid "Checking file extension {file_name}" msgstr "Überprüfen der Dateierweiterung {file_name}" @@ -72,6 +75,9 @@ msgstr "Ungültiger Wert: {reason}" msgid "Logger was not setup" msgstr "Logger wurde nicht eingerichtet" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Speicherfehler" @@ -84,6 +90,9 @@ msgstr "Speicherfehler: Speichern von {saving} in {file_name} fehlgeschlagen" msgid "Setting {name} to {value}" msgstr "Einstellung {name} auf {value}" +msgid "Submit" +msgstr "Senden" + msgid "Test String" msgstr "Testzeichenfolge" diff --git a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po index 792704b..d0d1571 100644 --- a/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po +++ b/src/moldflow/locale/en-US/LC_MESSAGES/locale.en-US.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: en-US\n" +msgid "Cancel" +msgstr "Cancel" + msgid "Checking file extension {file_name}" msgstr "Checking file extension {file_name}" @@ -72,6 +75,9 @@ msgstr "Invalid Value: {reason}" msgid "Logger was not setup" msgstr "Logger was not setup" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Save Error" @@ -84,6 +90,9 @@ msgstr "Save Error: Failed to save {saving} to {file_name}" msgid "Setting {name} to {value}" msgstr "Setting {name} to {value}" +msgid "Submit" +msgstr "Submit" + msgid "Test String" msgstr "Test String" diff --git a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po index ca8a638..ce166eb 100644 --- a/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po +++ b/src/moldflow/locale/es-ES/LC_MESSAGES/locale.es-ES.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: es-ES\n" +msgid "Cancel" +msgstr "Cancelar" + msgid "Checking file extension {file_name}" msgstr "Comprobando la extensión del archivo {file_name}" @@ -72,6 +75,9 @@ msgstr "Valor no válido: {reason}" msgid "Logger was not setup" msgstr "Logger no estaba configurado" +msgid "OK" +msgstr "Aceptar" + msgid "Save Error" msgstr "Error al guardar" @@ -84,6 +90,9 @@ msgstr "Error al guardar: No se pudo guardar {saving} en {file_name}" msgid "Setting {name} to {value}" msgstr "Configurar {name} a {value}" +msgid "Submit" +msgstr "Aceptar" + msgid "Test String" msgstr "Cadena de prueba" diff --git a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po index 43fce62..f45060e 100644 --- a/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po +++ b/src/moldflow/locale/fr-FR/LC_MESSAGES/locale.fr-FR.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: fr-FR\n" +msgid "Cancel" +msgstr "Annuler" + msgid "Checking file extension {file_name}" msgstr "Vérification de l'extension du fichier {file_name}" @@ -72,6 +75,9 @@ msgstr "Valeur non valide: {reason}" msgid "Logger was not setup" msgstr "Logger n'était pas configuré" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Erreur d'enregistrement" @@ -84,6 +90,9 @@ msgstr "Erreur d'enregistrement : échec de l'enregistrement de {saving} dans {f msgid "Setting {name} to {value}" msgstr "Définition de {name} sur {value}" +msgid "Submit" +msgstr "Valider" + msgid "Test String" msgstr "Chaîne de test" diff --git a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po index 3b257d0..693f216 100644 --- a/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po +++ b/src/moldflow/locale/it-IT/LC_MESSAGES/locale.it-IT.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: it-IT\n" +msgid "Cancel" +msgstr "Annulla" + msgid "Checking file extension {file_name}" msgstr "Verifica dell'estensione del file {file_name}" @@ -72,6 +75,9 @@ msgstr "Valore non valido: {reason}" msgid "Logger was not setup" msgstr "Logger non è stato configurato" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Errore di salvataggio" @@ -84,6 +90,9 @@ msgstr "Errore di salvataggio: salvataggio di {saving} in {file_name} non riusci msgid "Setting {name} to {value}" msgstr "Impostazione di {name} su {value}" +msgid "Submit" +msgstr "Conferma" + msgid "Test String" msgstr "Stringa di prova" diff --git a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po index 5f367eb..3a1dfc1 100644 --- a/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po +++ b/src/moldflow/locale/ja-JP/LC_MESSAGES/locale.ja-JP.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ja-JP\n" +msgid "Cancel" +msgstr "キャンセル" + msgid "Checking file extension {file_name}" msgstr "ファイル拡張子 {file_name} を確認しています" @@ -72,6 +75,9 @@ msgstr "無効な値: {reason}" msgid "Logger was not setup" msgstr "ロガーが設定されていませんでした" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "保存エラー" @@ -84,6 +90,9 @@ msgstr "保存エラー: {saving} を {file_name} に保存できませんでし msgid "Setting {name} to {value}" msgstr "{name} を {value} に設定しています" +msgid "Submit" +msgstr "OK" + msgid "Test String" msgstr "テスト文字列" diff --git a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po index 48752cd..5f6976a 100644 --- a/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po +++ b/src/moldflow/locale/ko-KR/LC_MESSAGES/locale.ko-KR.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: ko-KR\n" +msgid "Cancel" +msgstr "취소" + msgid "Checking file extension {file_name}" msgstr "파일 확장자 {file_name} 확인 중" @@ -72,6 +75,9 @@ msgstr "잘못된 값: {reason}" msgid "Logger was not setup" msgstr "로거가 설정되지 않았습니다" +msgid "OK" +msgstr "확인" + msgid "Save Error" msgstr "저장 오류" @@ -84,6 +90,9 @@ msgstr "저장 오류: {saving}을(를) {file_name}에 저장하지 못했습니 msgid "Setting {name} to {value}" msgstr "{name}을(를) {value}(으)로 설정 중" +msgid "Submit" +msgstr "확인" + msgid "Test String" msgstr "테스트 문자열" diff --git a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po index 9b6c70a..9b1a92a 100644 --- a/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po +++ b/src/moldflow/locale/pt-PT/LC_MESSAGES/locale.pt-PT.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: pt-PT\n" +msgid "Cancel" +msgstr "Cancelar" + msgid "Checking file extension {file_name}" msgstr "A verificar a extensão do ficheiro {file_name}" @@ -72,6 +75,9 @@ msgstr "Valor inválido: {reason}" msgid "Logger was not setup" msgstr "Logger não foi configurado" +msgid "OK" +msgstr "OK" + msgid "Save Error" msgstr "Erro ao guardar" @@ -84,6 +90,9 @@ msgstr "Erro ao guardar: falha ao guardar {saving} em {file_name}" msgid "Setting {name} to {value}" msgstr "Configuração {name} para {value}" +msgid "Submit" +msgstr "Submeter" + msgid "Test String" msgstr "String de teste" diff --git a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po index 8bc6aab..a73c1e2 100644 --- a/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po +++ b/src/moldflow/locale/zh-CN/LC_MESSAGES/locale.zh-CN.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-CN\n" +msgid "Cancel" +msgstr "取消" + msgid "Checking file extension {file_name}" msgstr "正在检查文件扩展名{file_name}" @@ -72,6 +75,9 @@ msgstr "无效的值:{reason}" msgid "Logger was not setup" msgstr "没有设置记录器" +msgid "OK" +msgstr "确定" + msgid "Save Error" msgstr "保存错误" @@ -84,6 +90,9 @@ msgstr "保存错误:将{saving}保存到{file_name}失败" msgid "Setting {name} to {value}" msgstr "将{name}设置为{value}" +msgid "Submit" +msgstr "确定" + msgid "Test String" msgstr "测试字符串" diff --git a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po index cd4019f..4ebf02b 100644 --- a/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po +++ b/src/moldflow/locale/zh-TW/LC_MESSAGES/locale.zh-TW.po @@ -3,6 +3,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: zh-TW\n" +msgid "Cancel" +msgstr "取消" + msgid "Checking file extension {file_name}" msgstr "正在檢查檔案副檔名 {file_name}" @@ -72,6 +75,9 @@ msgstr "無效的值:{reason}" msgid "Logger was not setup" msgstr "記錄器未設定" +msgid "OK" +msgstr "確定" + msgid "Save Error" msgstr "儲存錯誤" @@ -84,6 +90,9 @@ msgstr "儲存錯誤:將 {saving} 儲存到 {file_name} 失敗" msgid "Setting {name} to {value}" msgstr "正在將 {name} 設定為 {value}" +msgid "Submit" +msgstr "確定" + msgid "Test String" msgstr "測試字串" diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py new file mode 100644 index 0000000..54649ef --- /dev/null +++ b/src/moldflow/message_box.py @@ -0,0 +1,1378 @@ +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +MessageBox convenience wrapper for Moldflow scripts. + +Provides simple info/warning/error dialogs, confirmation prompts, and a text +input dialog. Uses Win32 MessageBox for standard dialogs and a lightweight +custom Win32 dialog (ctypes) for text input. +""" + +from enum import Enum, auto +from typing import Optional, Union, Callable, TypeAlias +from dataclasses import dataclass +import ctypes +import platform +from ctypes import windll, wintypes, byref, create_unicode_buffer, c_int, c_wchar_p, WINFUNCTYPE +import signal +import struct +from .i18n import get_text +from .logger import get_logger + +# This module intentionally contains a large amount of Windows interop glue +# and UI layout code. +# pylint: disable=C0301,C0302,R0902,W0212,R0911,R0914,R0902,W0201 + +# Fallbacks for missing wintypes aliases on some Python versions +if not hasattr(wintypes, "LRESULT"): + # LONG_PTR + wintypes.LRESULT = ctypes.c_ssize_t # type: ignore[attr-defined] +if not hasattr(wintypes, "HMENU"): + wintypes.HMENU = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HCURSOR"): + wintypes.HCURSOR = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HICON"): + wintypes.HICON = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HBRUSH"): + wintypes.HBRUSH = ctypes.c_void_p # type: ignore[attr-defined] +if not hasattr(wintypes, "HINSTANCE"): + wintypes.HINSTANCE = ctypes.c_void_p # type: ignore[attr-defined] + +# Extra Win32 constants used by CreateWindowEx path +WIN_WM_SETFONT = 0x0030 +WIN_WS_EX_DLGMODALFRAME = 0x00000001 +WIN_WS_EX_CONTROLPARENT = 0x00010000 +WIN_DEFAULT_CHARSET = 1 +WIN_OUT_DEFAULT_PRECIS = 0 +WIN_CLIP_DEFAULT_PRECIS = 0 +WIN_CLEARTYPE_QUALITY = 5 +WIN_DEFAULT_PITCH = 0 +WIN_FF_DONTCARE = 0 +WIN_FW_NORMAL = 400 +WIN_LOGPIXELSY = 90 +WIN_WM_CLOSE = 0x0010 +WIN_WM_KEYDOWN = 0x0100 +WIN_VK_RETURN = 0x0D +WIN_VK_ESCAPE = 0x1B + +# Helper alias for pointer-sized integer type used by Win32 callbacks +# Return type for DLGPROC should be an integer type matching pointer size, +# not a pointer type. Using a pointer type here can corrupt the stack on 64-bit. +# pylint: disable=invalid-name +INT_PTR = ctypes.c_ssize_t + + +# Win32 MessageBox flags (from winuser.h) +WIN_MB_OK = 0x00000000 +WIN_MB_OKCANCEL = 0x00000001 +WIN_MB_ABORTRETRYIGNORE = 0x00000002 +WIN_MB_YESNOCANCEL = 0x00000003 +WIN_MB_YESNO = 0x00000004 +WIN_MB_RETRYCANCEL = 0x00000005 +WIN_MB_CANCELTRYCONTINUE = 0x00000006 + +WIN_MB_ICONERROR = 0x00000010 +WIN_MB_ICONQUESTION = 0x00000020 +WIN_MB_ICONWARNING = 0x00000030 +WIN_MB_ICONINFORMATION = 0x00000040 + +WIN_MB_DEFBUTTON2 = 0x00000100 +WIN_MB_DEFBUTTON3 = 0x00000200 +WIN_MB_DEFBUTTON4 = 0x00000300 + +WIN_MB_SYSTEMMODAL = 0x00001000 +WIN_MB_TASKMODAL = 0x00002000 +WIN_MB_HELP = 0x00004000 +WIN_MB_SETFOREGROUND = 0x00010000 +WIN_MB_TOPMOST = 0x00040000 +WIN_MB_RIGHT = 0x00080000 +WIN_MB_RTLREADING = 0x00100000 + +# Win32 MessageBox return IDs +WIN_IDOK = 1 +WIN_IDCANCEL = 2 +WIN_IDABORT = 3 +WIN_IDRETRY = 4 +WIN_IDIGNORE = 5 +WIN_IDYES = 6 +WIN_IDNO = 7 +WIN_IDTRYAGAIN = 10 +WIN_IDCONTINUE = 11 + +# Win32 dialog and control style flags (used by input dialog) +WIN_DS_SETFONT = 0x00000040 +WIN_DS_MODALFRAME = 0x00000080 +WIN_WS_CAPTION = 0x00C00000 +WIN_WS_SYSMENU = 0x00080000 +WIN_WS_POPUP = 0x80000000 + +WIN_WS_CHILD = 0x40000000 +WIN_WS_VISIBLE = 0x10000000 +WIN_WS_TABSTOP = 0x00010000 +WIN_WS_GROUP = 0x00020000 +WIN_WS_BORDER = 0x00800000 +WIN_WS_THICKFRAME = 0x00040000 +WIN_WS_MINIMIZEBOX = 0x00020000 +WIN_WS_MAXIMIZEBOX = 0x00010000 + +WIN_ES_AUTOHSCROLL = 0x00000080 +WIN_ES_PASSWORD = 0x00000020 +WIN_SS_LEFT = 0x00000000 +WIN_BS_DEFPUSHBUTTON = 0x00000001 +WIN_BS_PUSHBUTTON = 0x00000000 + +# Window messages +WIN_WM_INITDIALOG = 0x0110 +WIN_WM_COMMAND = 0x0111 +WIN_WM_CTLCOLORSTATIC = 0x0138 + +# Edit control helpers +WIN_EM_SETCUEBANNER = 0x1501 +WIN_EN_CHANGE = 0x0300 +WIN_EM_LIMITTEXT = 0x00C5 + +# DrawText flags +WIN_DT_WORDBREAK = 0x0010 +WIN_DT_CALCRECT = 0x0400 +WIN_DT_NOPREFIX = 0x0800 + +# SetWindowPos flags and system metrics +WIN_SWP_NOSIZE = 0x0001 +WIN_SWP_NOZORDER = 0x0004 +WIN_SWP_NOACTIVATE = 0x0010 +WIN_SM_CXSCREEN = 0 +WIN_SM_CYSCREEN = 1 + +# Predefined control classes (atoms from winuser.h) +# 0x0080: BUTTON, 0x0081: EDIT, 0x0082: STATIC +WIN_CLASS_BUTTON = 0x0080 +WIN_CLASS_EDIT = 0x0081 +WIN_CLASS_STATIC = 0x0082 + +# Control IDs +WIN_ID_EDIT = 1001 +WIN_ID_OK = 1 +WIN_ID_CANCEL = 2 + +# Defaults +DEFAULT_TITLE = "Moldflow" + + +class MessageBoxType(Enum): + """ + Message box types supported by the convenience API. + + - INFO: Informational message with OK button + - WARNING: Warning message with OK button + - ERROR: Error message with OK button + - YES_NO: Confirmation dialog with Yes/No buttons + - YES_NO_CANCEL: Confirmation dialog with Yes/No/Cancel buttons + - OK_CANCEL: Prompt with OK/Cancel buttons + - RETRY_CANCEL: Prompt with Retry/Cancel buttons + - ABORT_RETRY_IGNORE: Prompt with Abort/Retry/Ignore buttons + - CANCEL_TRY_CONTINUE: Prompt with Cancel/Try Again/Continue buttons + - INPUT: Text input dialog returning a string + """ + + INFO = auto() + WARNING = auto() + ERROR = auto() + YES_NO = auto() + YES_NO_CANCEL = auto() + OK_CANCEL = auto() + RETRY_CANCEL = auto() + ABORT_RETRY_IGNORE = auto() + CANCEL_TRY_CONTINUE = auto() + INPUT = auto() + + +class MessageBoxResult(Enum): + """ + Result of a message box interaction. + + For INPUT type, the MessageBox.show() method returns a string rather than + a MessageBoxResult. For other types, it returns one of these values. + """ + + OK = auto() + CANCEL = auto() + YES = auto() + NO = auto() + RETRY = auto() + ABORT = auto() + IGNORE = auto() + TRY_AGAIN = auto() + CONTINUE = auto() + + +# Public type alias for show() return value +MessageBoxReturn: TypeAlias = Union[MessageBoxResult, Optional[str]] + + +class MessageBoxIcon(Enum): + """ + Icon to display on the message box. If not provided, a sensible default is + chosen based on the MessageBoxType. + """ + + NONE = auto() + INFORMATION = auto() + WARNING = auto() + ERROR = auto() + QUESTION = auto() + + +class MessageBoxModality(Enum): + """Modality for the message box window.""" + + APPLICATION = auto() # Default Win32 behavior (no explicit flag) + SYSTEM = auto() + TASK = auto() + + +class MessageBoxDefaultButton(Enum): + """Which button is the default (activated by Enter).""" + + BUTTON1 = auto() + BUTTON2 = auto() + BUTTON3 = auto() + BUTTON4 = auto() + + +# Mapping dictionaries (module-level) for flags and results +MAPPING_MESSAGEBOX_TYPE = { + MessageBoxType.INFO: (WIN_MB_OK, MessageBoxIcon.INFORMATION, 1), + MessageBoxType.WARNING: (WIN_MB_OK, MessageBoxIcon.WARNING, 1), + MessageBoxType.ERROR: (WIN_MB_OK, MessageBoxIcon.ERROR, 1), + MessageBoxType.YES_NO: (WIN_MB_YESNO, MessageBoxIcon.QUESTION, 2), + MessageBoxType.YES_NO_CANCEL: (WIN_MB_YESNOCANCEL, MessageBoxIcon.QUESTION, 3), + MessageBoxType.OK_CANCEL: (WIN_MB_OKCANCEL, MessageBoxIcon.INFORMATION, 2), + MessageBoxType.RETRY_CANCEL: (WIN_MB_RETRYCANCEL, MessageBoxIcon.WARNING, 2), + MessageBoxType.ABORT_RETRY_IGNORE: (WIN_MB_ABORTRETRYIGNORE, MessageBoxIcon.ERROR, 3), + MessageBoxType.CANCEL_TRY_CONTINUE: (WIN_MB_CANCELTRYCONTINUE, MessageBoxIcon.WARNING, 3), +} + +ICON_TO_FLAG = { + MessageBoxIcon.INFORMATION: WIN_MB_ICONINFORMATION, + MessageBoxIcon.WARNING: WIN_MB_ICONWARNING, + MessageBoxIcon.ERROR: WIN_MB_ICONERROR, + MessageBoxIcon.QUESTION: WIN_MB_ICONQUESTION, +} + +DEFAULT_BUTTON_TO_FLAG = { + MessageBoxDefaultButton.BUTTON2: (WIN_MB_DEFBUTTON2, 2), + MessageBoxDefaultButton.BUTTON3: (WIN_MB_DEFBUTTON3, 3), + MessageBoxDefaultButton.BUTTON4: (WIN_MB_DEFBUTTON4, 4), +} + +MODALITY_TO_FLAG = { + MessageBoxModality.SYSTEM: WIN_MB_SYSTEMMODAL, + MessageBoxModality.TASK: WIN_MB_TASKMODAL, +} + +ID_TO_RESULT = { + WIN_IDOK: MessageBoxResult.OK, + WIN_IDCANCEL: MessageBoxResult.CANCEL, + WIN_IDYES: MessageBoxResult.YES, + WIN_IDNO: MessageBoxResult.NO, + WIN_IDRETRY: MessageBoxResult.RETRY, + WIN_IDABORT: MessageBoxResult.ABORT, + WIN_IDIGNORE: MessageBoxResult.IGNORE, + WIN_IDTRYAGAIN: MessageBoxResult.TRY_AGAIN, + WIN_IDCONTINUE: MessageBoxResult.CONTINUE, +} + + +@dataclass(frozen=True) +class MessageBoxOptions: # pylint: disable=too-many-instance-attributes + """ + Optional advanced options for MessageBox. + + - icon: Overrides the default icon + - default_button: Choose default button (2/3/4). BUTTON1 is implicit default + - topmost: Keep message box on top of other windows + - modality: Application (default), Task-modal, or System-modal + - rtl_reading: Use right-to-left reading order + - right_align: Right align the message text + - help_button: Show a Help button + - set_foreground: Force the message box to the foreground + """ + + icon: Optional[MessageBoxIcon] = None + default_button: Optional[MessageBoxDefaultButton] = None + topmost: bool = False + modality: Optional[MessageBoxModality] = None + rtl_reading: bool = False + right_align: bool = False + help_button: bool = False + set_foreground: bool = False + owner_hwnd: Optional[int] = None + # Input dialog enhancements + default_text: Optional[str] = None + placeholder: Optional[str] = None + validator: Optional[Callable[[str], bool]] = None + font_face: str = "Segoe UI" + font_size_pt: int = 9 + is_password: bool = False + char_limit: Optional[int] = None + width_dlu: Optional[int] = None + height_dlu: Optional[int] = None + + def __post_init__(self) -> None: + # Normalize strings + normalized_face = (self.font_face or "Segoe UI").strip() + object.__setattr__(self, "font_face", normalized_face or "Segoe UI") + + # Clamp font size + size = self.font_size_pt + if not isinstance(size, int): + try: + size = int(size) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Font size parse failed; defaulting to 9: %s", exc) + size = 9 + # Clamp font size between sensible bounds + size = max(6, min(size, 24)) + object.__setattr__(self, "font_size_pt", size) + + # Owner HWND must be non-negative + if self.owner_hwnd is not None and self.owner_hwnd < 0: + object.__setattr__(self, "owner_hwnd", 0) + + # Normalize default_text/placeholder + if self.default_text is not None: + object.__setattr__(self, "default_text", str(self.default_text)) + if self.placeholder is not None: + object.__setattr__(self, "placeholder", str(self.placeholder)) + + # Validate char_limit + if self.char_limit is not None and self.char_limit < 0: + object.__setattr__(self, "char_limit", 0) + + +class MessageBox: + """ + MessageBox convenience class. + + Example: + .. code-block:: python + from moldflow import MessageBox, MessageBoxType + + # Information message + MessageBox("Operation completed.", MessageBoxType.INFO).show() + + # Yes/No prompt + result = MessageBox("Proceed with analysis?", MessageBoxType.YES_NO).show() + if result == MessageBoxResult.YES: + ... + + # Text input + material_id = MessageBox("Enter your material ID:", MessageBoxType.INPUT).show() + if material_id: + ... + """ + + def __init__( + self, + text: str, + box_type: MessageBoxType = MessageBoxType.INFO, + title: Optional[str] = None, + options: Optional[MessageBoxOptions] = None, + ) -> None: + if platform.system() != "Windows": + raise OSError("MessageBox is only supported on Windows.") + self.text = str(text) + self.box_type = box_type + self.title = title or DEFAULT_TITLE + self.options = options or MessageBoxOptions() + + def show(self) -> MessageBoxReturn: + """ + Show the message box. + + Returns: + - MessageBoxResult for INFO/WARNING/ERROR/YES_NO/OK_CANCEL + - str | None for INPUT (user-entered text or None if cancelled) + """ + + if self.box_type == MessageBoxType.INPUT: + return self._show_input_dialog() + return self._show_standard_dialog() + + @classmethod + def info( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + """ + Show an informational message box with an OK button. + """ + inst = cls(text, MessageBoxType.INFO, title, options) + return inst.show() # type: ignore[return-value] + + @classmethod + def warning( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + """ + Show a warning message box with an OK button. + """ + inst = cls(text, MessageBoxType.WARNING, title, options) + return inst.show() # type: ignore[return-value] + + @classmethod + def error( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + """ + Show an error message box with an OK button. + """ + inst = cls(text, MessageBoxType.ERROR, title, options) + return inst.show() # type: ignore[return-value] + + @classmethod + def confirm_yes_no( + cls, text: str, title: Optional[str] = None, options: Optional[MessageBoxOptions] = None + ) -> MessageBoxResult: + """ + Show a confirmation message box with Yes/No buttons. + """ + return cls(text, MessageBoxType.YES_NO, title, options).show() # type: ignore[return-value] + + @classmethod + def prompt_text( # pylint: disable=too-many-arguments,too-many-positional-arguments + cls, + prompt: str, + title: Optional[str] = None, + default_text: Optional[str] = None, + placeholder: Optional[str] = None, + validator: Optional[Callable[[str], bool]] = None, + options: Optional[MessageBoxOptions] = None, + ) -> Optional[str]: + """ + Show a text input dialog. + """ + opts = options or MessageBoxOptions() + # Merge provided options with overrides for input UX + opts = MessageBoxOptions( + icon=opts.icon, + default_button=opts.default_button, + topmost=opts.topmost, + modality=opts.modality, + rtl_reading=opts.rtl_reading, + right_align=opts.right_align, + help_button=opts.help_button, + set_foreground=opts.set_foreground, + owner_hwnd=opts.owner_hwnd, + default_text=default_text if default_text is not None else opts.default_text, + placeholder=placeholder if placeholder is not None else opts.placeholder, + validator=validator if validator is not None else opts.validator, + font_face=opts.font_face, + font_size_pt=opts.font_size_pt, + ) + return cls(prompt, MessageBoxType.INPUT, title, opts).show() # type: ignore[return-value] + + def _show_standard_dialog(self) -> MessageBoxResult: + """ + Show a standard Win32 MessageBox dialog and return the result. + """ + # Use module-level ctypes imports to avoid reimport and name shadowing + + # Base type from box_type via module-level mapping dict + base_tuple = MAPPING_MESSAGEBOX_TYPE.get( + self.box_type, (WIN_MB_OK, MessageBoxIcon.INFORMATION, 1) + ) + u_type, default_icon, button_count = base_tuple + + # Icon selection (options override default) + icon = self.options.icon or default_icon + u_type |= ICON_TO_FLAG.get(icon, 0) + # NONE -> no icon flag + + # Default button + if self.options.default_button: + flag, required = DEFAULT_BUTTON_TO_FLAG.get(self.options.default_button, (0, 1)) + if button_count < required: + # The error message is intentionally descriptive; allow a + # slightly longer line here rather than make it unreadable. + # pylint: disable=line-too-long + raise ValueError( + f"default_button {self.options.default_button.name} requires >={required} buttons for {self.box_type.name}" + ) + u_type |= flag + + # Modality + if self.options.modality: + u_type |= MODALITY_TO_FLAG.get(self.options.modality, 0) + + # Z-order / positioning + if self.options.topmost: + u_type |= WIN_MB_TOPMOST + if self.options.set_foreground: + u_type |= WIN_MB_SETFOREGROUND + + # Layout + if self.options.right_align: + u_type |= WIN_MB_RIGHT + if self.options.rtl_reading: + u_type |= WIN_MB_RTLREADING + + # Help button + if self.options.help_button: + u_type |= WIN_MB_HELP + + owner = self.options.owner_hwnd or 0 + # Trim whitespace to avoid accidental spaces + text = (self.text or "").strip() + # Do not translate titles + title = (self.title or "").strip() + result = windll.user32.MessageBoxW(owner, c_wchar_p(text), c_wchar_p(title), c_int(u_type)) + if result == -1: + err = windll.kernel32.GetLastError() + raise ctypes.WinError(err) + + if result in ID_TO_RESULT: + return ID_TO_RESULT[result] + # Fallback + return MessageBoxResult.CANCEL + + def _show_input_dialog(self) -> Optional[str]: + """ + Show a text input dialog. + """ + dialog = _Win32InputDialog(self.title, self.text, self.options) + return dialog.run() + + +class _Win32InputDialog: + """ + Modal input dialog using DialogBoxIndirectParamW with an in-memory DLGTEMPLATE. + """ + + ID_EDIT = WIN_ID_EDIT + ID_OK = WIN_ID_OK + ID_CANCEL = WIN_ID_CANCEL + + DS_SETFONT = WIN_DS_SETFONT + DS_MODALFRAME = WIN_DS_MODALFRAME + WS_CAPTION = WIN_WS_CAPTION + WS_SYSMENU = WIN_WS_SYSMENU + + WS_CHILD = WIN_WS_CHILD + WS_VISIBLE = WIN_WS_VISIBLE + WS_TABSTOP = WIN_WS_TABSTOP + WS_GROUP = WIN_WS_GROUP + WS_BORDER = WIN_WS_BORDER + + ES_AUTOHSCROLL = WIN_ES_AUTOHSCROLL + ES_PASSWORD = WIN_ES_PASSWORD + SS_LEFT = WIN_SS_LEFT + BS_DEFPUSHBUTTON = WIN_BS_DEFPUSHBUTTON + BS_PUSHBUTTON = WIN_BS_PUSHBUTTON + + WM_INITDIALOG = WIN_WM_INITDIALOG + WM_COMMAND = WIN_WM_COMMAND + + def __init__(self, title: str, prompt: str, options: MessageBoxOptions) -> None: + self.title = title + self.prompt = prompt + self.options = options + self._result_text: Optional[str] = None + # Template buffer is created when running the dialog; initialize attribute + self._template_buffer: Optional[bytes] = None + + def _wcs(self, s: str) -> bytes: + """Return a UTF-16LE encoded, null-terminated bytestring for s.""" + return s.encode("utf-16le") + b"\x00\x00" + + def _align_dword(self, buf: bytearray) -> None: + """Pad buffer until its length is a multiple of 4 (DWORD alignment).""" + while len(buf) % 4 != 0: + buf += b"\x00" + + def _pack_word(self, buf: bytearray, val: int) -> None: + """Pack a 16-bit unsigned value into the buffer.""" + buf += struct.pack(" None: + """Pack a 32-bit unsigned value into the buffer.""" + buf += struct.pack(" None: + """Pack a 16-bit signed value into the buffer.""" + buf += struct.pack(" bytes: + # The dialog template is relatively verbose; allow pylint to accept the + # complexity here rather than refactor the Win32 packing code. + # pylint: disable=too-many-locals,too-many-statements + # Dialog units and layout + cx = self.options.width_dlu if self.options.width_dlu is not None else 240 + cy = self.options.height_dlu if self.options.height_dlu is not None else 70 + margin = 7 + static_h = 8 + edit_h = 12 + btn_w, btn_h = 50, 14 + spacing = 4 + + ok_x = cx - margin - (btn_w * 2 + spacing) + cancel_x = cx - margin - btn_w + # Position the edit box a bit lower from the label + edit_y = margin + static_h + 8 + # Move the buttons up: place them below the edit with extra spacing + btn_y = edit_y + edit_h + spacing * 2 + + buf = bytearray() + + style = ( + self.DS_MODALFRAME | self.DS_SETFONT | self.WS_CAPTION | self.WS_SYSMENU | WIN_WS_POPUP + ) + self._pack_dword(buf, style) # style + self._pack_dword(buf, 0) # dwExtendedStyle + self._pack_word(buf, 4) # cdit: static, edit, OK, Cancel + self._pack_short(buf, margin) # x + self._pack_short(buf, margin) # y + self._pack_short(buf, cx) # cx + self._pack_short(buf, cy) # cy + + self._pack_word(buf, 0) # menu = 0 + self._pack_word(buf, 0) # windowClass = 0 (default) + # Do not translate titles + buf += self._wcs(self.title) # title + + # Font (since DS_SETFONT) + self._pack_word(buf, max(6, int(self.options.font_size_pt))) # point size + buf += self._wcs(self.options.font_face or "Segoe UI") + + # DLGITEMTEMPLATEs must be DWORD-aligned + # 1) Static: prompt + self._align_dword(buf) + self._pack_dword(buf, self.WS_CHILD | self.WS_VISIBLE) + self._pack_dword(buf, 0) # ex style + self._pack_short(buf, margin) + self._pack_short(buf, margin) + self._pack_short(buf, cx - 2 * margin) + self._pack_short(buf, static_h) + self._pack_word(buf, 0) # id for static is usually 0 + # class: 0xFFFF, 0x0082 (STATIC) + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_STATIC) + # Do not translate prompt; callers pass text explicitly + buf += self._wcs(self.prompt) # title + self._pack_word(buf, 0) # no extra data + + # 2) Edit control + self._align_dword(buf) + edit_style = ( + self.WS_CHILD | self.WS_VISIBLE | self.WS_BORDER | self.ES_AUTOHSCROLL | self.WS_TABSTOP + ) + if self.options.is_password: + edit_style |= self.ES_PASSWORD + self._pack_dword(buf, edit_style) + self._pack_dword(buf, 0) + self._pack_short(buf, margin) + self._pack_short(buf, margin + static_h + 2) + self._pack_short(buf, cx - 2 * margin) + self._pack_short(buf, edit_h) + self._pack_word(buf, self.ID_EDIT) + # class: 0xFFFF, 0x0080 EDIT + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_EDIT) + self._pack_word(buf, 0) # empty text + self._pack_word(buf, 0) # no extra data + + _ = get_text() + + # 3) OK button (default) + self._align_dword(buf) + self._pack_dword( + buf, + self.WS_CHILD + | self.WS_VISIBLE + | self.WS_TABSTOP + | self.WS_GROUP + | self.BS_DEFPUSHBUTTON, + ) + self._pack_dword(buf, 0) + self._pack_short(buf, ok_x) + self._pack_short(buf, btn_y) + self._pack_short(buf, btn_w) + self._pack_short(buf, btn_h) + self._pack_word(buf, self.ID_OK) + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_BUTTON) + buf += self._wcs(_("OK")) + self._pack_word(buf, 0) + + # 4) Cancel button + self._align_dword(buf) + self._pack_dword( + buf, self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_PUSHBUTTON + ) + self._pack_dword(buf, 0) + self._pack_short(buf, cancel_x) + self._pack_short(buf, btn_y) + self._pack_short(buf, btn_w) + self._pack_short(buf, btn_h) + self._pack_word(buf, self.ID_CANCEL) + self._pack_word(buf, 0xFFFF) + self._pack_word(buf, WIN_CLASS_BUTTON) + buf += self._wcs(_("Cancel")) + self._pack_word(buf, 0) + + self._align_dword(buf) + return bytes(buf) + + def run(self) -> Optional[str]: + """Create and run a modal input window using CreateWindowEx.""" + # pylint: disable=too-many-locals,too-many-branches,too-many-statements,invalid-name + user32 = windll.user32 + gdi32 = windll.gdi32 + kernel32 = windll.kernel32 + + # Win32 function prototypes used + try: + user32.CreateWindowExW.restype = wintypes.HWND + user32.CreateWindowExW.argtypes = [ + wintypes.DWORD, + c_wchar_p, + c_wchar_p, + wintypes.DWORD, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + wintypes.HWND, + wintypes.HMENU, + wintypes.HINSTANCE, + wintypes.LPVOID, + ] + user32.DefWindowProcW.restype = wintypes.LRESULT + user32.DefWindowProcW.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + ] + user32.RegisterClassW.restype = wintypes.ATOM + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Win32 prototype binding failed: %s", exc) + + # Register window class once + class_name = "MF_InputDialogWindow" + if not hasattr(_Win32InputDialog, "_class_registered"): + WNDPROC = WINFUNCTYPE( + wintypes.LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM + ) + + @WNDPROC + def _wndproc(hwnd, msg, wparam, lparam): + # Retrieve instance from map if present + inst = _Win32InputDialog._hwnd_to_inst.get(hwnd) + if msg == WIN_WM_CLOSE: + windll.user32.DestroyWindow(hwnd) + return 0 + if msg == WIN_WM_KEYDOWN and inst is not None: + if wparam == WIN_VK_RETURN: + inst._on_ok() + return 0 + if wparam == WIN_VK_ESCAPE: + inst._on_cancel() + return 0 + if msg == 0x0002: # WM_DESTROY + if inst is not None: + # Defer destruction finalization slightly to allow any + # late WM_COMMAND or automation posts to drain safely. + try: + inst._on_destroy() + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("_on_destroy raised: %s", exc) + return 0 + if msg == 0x0082: # WM_NCDESTROY + try: + if inst is not None: + inst._done = True # type: ignore[attr-defined] + _Win32InputDialog._hwnd_to_inst.pop(hwnd, None) + # Ensure the modal loop unblocks even if no further messages arrive + user32.PostQuitMessage(0) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("WM_NCDESTROY cleanup failed: %s", exc) + return 0 + if inst is None: + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + if msg == 0x0005: # WM_SIZE + inst._on_size() + return 0 + if msg == WIN_WM_CTLCOLORSTATIC: + # Make label background match dialog background for a flat look + try: + windll.gdi32.SetBkMode(wparam, 1) # TRANSPARENT + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("SetBkMode transparent failed: %s", exc) + return getattr(_Win32InputDialog, "_bg_brush", 0) + if msg == _Win32InputDialog.WM_COMMAND: + cid = wparam & 0xFFFF + notify = (wparam >> 16) & 0xFFFF + # Ignore commands from unknown HWNDs to avoid processing + # stale messages after controls are destroyed. + if lparam not in (inst.h_edit, inst.h_ok, inst.h_cancel): + return 0 + if ( + notify == WIN_EN_CHANGE + and inst.options.validator is not None + and lparam == inst.h_edit + ): + inst._validate_live() + return 0 + if cid == inst.ID_OK: + inst._on_ok() + return 0 + if cid == inst.ID_CANCEL: + inst._on_cancel() + return 0 + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + _Win32InputDialog._WNDPROC = _wndproc # type: ignore[attr-defined] + + class WNDCLASSEX(ctypes.Structure): + """WNDCLASSEX structure""" + + _fields_ = [ + ("cbSize", wintypes.UINT), + ("style", wintypes.UINT), + ("lpfnWndProc", WNDPROC), + ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), + ("hInstance", wintypes.HINSTANCE), + ("hIcon", wintypes.HICON), + ("hCursor", wintypes.HCURSOR), + ("hbrBackground", wintypes.HBRUSH), + ("lpszMenuName", c_wchar_p), + ("lpszClassName", c_wchar_p), + ("hIconSm", wintypes.HICON), + ] + + # Prototypes for class registration + try: + user32.RegisterClassExW.restype = wintypes.ATOM + user32.RegisterClassExW.argtypes = [ctypes.POINTER(WNDCLASSEX)] + user32.LoadCursorW.restype = wintypes.HCURSOR + # Second parameter is MAKEINTRESOURCE on system cursors; accept as void* + user32.LoadCursorW.argtypes = [wintypes.HINSTANCE, ctypes.c_void_p] + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("RegisterClassEx/LoadCursor prototype bind failed: %s", exc) + + hInstance = kernel32.GetModuleHandleW(None) + wcx = WNDCLASSEX() + wcx.cbSize = ctypes.sizeof(WNDCLASSEX) + wcx.style = 0 + wcx.lpfnWndProc = _Win32InputDialog._WNDPROC # type: ignore[attr-defined] + wcx.cbClsExtra = 0 + wcx.cbWndExtra = 0 + wcx.hInstance = hInstance + wcx.hIcon = None + # IDC_ARROW = 32512 (0x7F00). Pass as MAKEINTRESOURCE via c_void_p + wcx.hCursor = windll.user32.LoadCursorW(None, ctypes.c_void_p(32512)) + # Use COLOR_WINDOW+1 to avoid theme brush quirks under automation + wcx.hbrBackground = ctypes.c_void_p(5 + 1) + wcx.lpszMenuName = None + wcx.lpszClassName = class_name + wcx.hIconSm = None + res = user32.RegisterClassExW(ctypes.byref(wcx)) + # If already registered, res==0 with last error 1410 (ERROR_CLASS_ALREADY_EXISTS) + if not res: + err = kernel32.GetLastError() + if err != 1410: # ERROR_CLASS_ALREADY_EXISTS + raise ctypes.WinError(err) + _Win32InputDialog._class_registered = True # type: ignore[attr-defined] + _Win32InputDialog._class_name = class_name # type: ignore[attr-defined] + _Win32InputDialog._hwnd_to_inst = {} # type: ignore[attr-defined] + # Cache background brush so STATIC controls can paint with same bg + try: + _Win32InputDialog._bg_brush = int(wcx.hbrBackground) # type: ignore[attr-defined] + except Exception as exc: + _Win32InputDialog._bg_brush = 0 # type: ignore[attr-defined] + logger = get_logger("message_box") + if logger: + logger.debug("Caching bg brush failed: %s", exc) + + # Create window + style = ( + self.WS_CAPTION + | self.WS_SYSMENU + | WIN_WS_POPUP + | WIN_WS_THICKFRAME + | WIN_WS_MINIMIZEBOX + | WIN_WS_MAXIMIZEBOX + ) + ex_style = WIN_WS_EX_DLGMODALFRAME | WIN_WS_EX_CONTROLPARENT + # Avoid cross-thread/process owner interactions; keep window independent + owner = 0 + + # Size and layout (pixels) + # Slightly larger default size so action buttons are always visible + cx = int(self.options.width_dlu if self.options.width_dlu is not None else 420) + cy = int(self.options.height_dlu if self.options.height_dlu is not None else 220) + margin = 36 + static_h = 22 + edit_h = 22 + btn_w, btn_h = 96, 28 + spacing = 16 + + ok_x = cx - margin - (btn_w * 2 + spacing) + cancel_x = cx - margin - btn_w + edit_y = margin + static_h + 8 + btn_y = edit_y + edit_h + spacing * 2 + + # Persist layout metrics for resize handling + self._layout_margin = margin # type: ignore[attr-defined] + self._layout_spacing = spacing # type: ignore[attr-defined] + self._layout_edit_h = edit_h # type: ignore[attr-defined] + self._layout_btn_w = btn_w # type: ignore[attr-defined] + self._layout_btn_h = btn_h # type: ignore[attr-defined] + + hInstance = kernel32.GetModuleHandleW(None) + hwnd = user32.CreateWindowExW( + ex_style, + c_wchar_p(getattr(_Win32InputDialog, "_class_name", class_name)), + c_wchar_p(self.title), + style, + 100, + 100, + cx, + cy, + None, + None, + hInstance, + None, + ) + if not hwnd: + err = kernel32.GetLastError() + raise ctypes.WinError(err) + + # Map hwnd to instance + _Win32InputDialog._hwnd_to_inst[hwnd] = self # type: ignore[attr-defined] + self.hwnd = hwnd # type: ignore[attr-defined] + + # Allow Ctrl+C in the console to close the window gracefully (both + # Python-level SIGINT and native console control handler for immediate response) + def _sigint_handler(_signum, _frame): + try: + user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Posting WM_CLOSE on SIGINT failed: %s", exc) + + try: + self._prev_sigint = signal.getsignal(signal.SIGINT) # type: ignore[attr-defined] + signal.signal(signal.SIGINT, _sigint_handler) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Setting SIGINT handler failed: %s", exc) + + # Native console control handler (fires immediately even while Python blocks) + try: + HANDLER_ROUTINE = WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD) + + @HANDLER_ROUTINE + def _console_ctrl_handler(_ctrl_type): # CTRL_C_EVENT, CTRL_BREAK_EVENT, etc. + try: + user32.PostMessageW(self.hwnd, WIN_WM_CLOSE, 0, 0) # type: ignore[attr-defined] + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Posting WM_CLOSE on console control failed: %s", exc) + return True + + kernel32.SetConsoleCtrlHandler.restype = wintypes.BOOL + kernel32.SetConsoleCtrlHandler.argtypes = [HANDLER_ROUTINE, wintypes.BOOL] + kernel32.SetConsoleCtrlHandler(_console_ctrl_handler, True) + self._console_ctrl_handler = _console_ctrl_handler # type: ignore[attr-defined] + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Setting console control handler failed: %s", exc) + + # Create child controls + self.h_static = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("STATIC"), + c_wchar_p(self.prompt), + self.WS_CHILD | self.WS_VISIBLE | self.SS_LEFT, + margin, + margin, + cx - 2 * margin, + static_h, + hwnd, + wintypes.HMENU(0), + hInstance, + None, + ) + edit_style = ( + self.WS_CHILD | self.WS_VISIBLE | self.WS_BORDER | self.ES_AUTOHSCROLL | self.WS_TABSTOP + ) + if self.options.is_password: + edit_style |= self.ES_PASSWORD + self.h_edit = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("EDIT"), + c_wchar_p(""), + edit_style, + margin, + edit_y, + cx - 2 * margin, + edit_h, + hwnd, + wintypes.HMENU(self.ID_EDIT), + hInstance, + None, + ) + _ = get_text() + self.h_ok = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("BUTTON"), + c_wchar_p(_("Submit")), + self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_DEFPUSHBUTTON, + ok_x, + btn_y, + btn_w, + btn_h, + hwnd, + wintypes.HMENU(self.ID_OK), + hInstance, + None, + ) + self.h_cancel = user32.CreateWindowExW( # type: ignore[attr-defined] + 0, + c_wchar_p("BUTTON"), + c_wchar_p(_("Cancel")), + self.WS_CHILD | self.WS_VISIBLE | self.WS_TABSTOP | self.BS_PUSHBUTTON, + cancel_x, + btn_y, + btn_w, + btn_h, + hwnd, + wintypes.HMENU(self.ID_CANCEL), + hInstance, + None, + ) + + # Apply a system dialog font for consistent look and spacing + try: + DEFAULT_GUI_FONT = 17 + hfont = windll.gdi32.GetStockObject(DEFAULT_GUI_FONT) + if hfont: + # Send WM_SETFONT to children so they repaint with the font + for hchild in (self.h_static, self.h_edit, self.h_ok, self.h_cancel): # type: ignore[attr-defined] + if hchild: + user32.SendMessageW(hchild, WIN_WM_SETFONT, hfont, 1) + # Keep a reference so it survives until window is destroyed + self._hfont = hfont # type: ignore[attr-defined] + + # Adjust edit height to match font metrics so caret is visually centered + class TEXTMETRICW(ctypes.Structure): + """TEXTMETRICW structure""" + + _fields_ = [ + ("tmHeight", ctypes.c_long), + ("tmAscent", ctypes.c_long), + ("tmDescent", ctypes.c_long), + ("tmInternalLeading", ctypes.c_long), + ("tmExternalLeading", ctypes.c_long), + ("tmAveCharWidth", ctypes.c_long), + ("tmMaxCharWidth", ctypes.c_long), + ("tmWeight", ctypes.c_long), + ("tmOverhang", ctypes.c_long), + ("tmDigitizedAspectX", ctypes.c_long), + ("tmDigitizedAspectY", ctypes.c_long), + # Next four fields are WCHAR in the Win32 API, keep for structure parity + ("tmFirstChar", ctypes.c_wchar), + ("tmLastChar", ctypes.c_wchar), + ("tmDefaultChar", ctypes.c_wchar), + ("tmBreakChar", ctypes.c_wchar), + ("tmItalic", ctypes.c_ubyte), + ("tmUnderlined", ctypes.c_ubyte), + ("tmStruckOut", ctypes.c_ubyte), + ("tmPitchAndFamily", ctypes.c_ubyte), + ("tmCharSet", ctypes.c_ubyte), + ] + + hdc_edit = user32.GetDC(self.h_edit) + if hdc_edit: + try: + prev = gdi32.SelectObject(hdc_edit, hfont) + tm = TEXTMETRICW() + if gdi32.GetTextMetricsW(hdc_edit, ctypes.byref(tm)): + desired_h = int(tm.tmHeight + tm.tmExternalLeading + 6) + desired_h = max(desired_h, 18) + # Resize edit control to the desired height and keep x/width constant + user32.SetWindowPos( + self.h_edit, + 0, + margin, + edit_y, + cx - 2 * margin, + desired_h, + WIN_SWP_NOZORDER, + ) + # Reposition buttons directly below the edit + new_btn_y = edit_y + desired_h + spacing * 2 + user32.SetWindowPos( + self.h_ok, + 0, + ok_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + user32.SetWindowPos( + self.h_cancel, + 0, + cancel_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + if prev: + gdi32.SelectObject(hdc_edit, prev) + finally: + user32.ReleaseDC(self.h_edit, hdc_edit) + + # Recalculate static height for long titles and wrap + hdc_static = user32.GetDC(self.h_static) + if hdc_static: + try: + prev2 = gdi32.SelectObject(hdc_static, hfont) + rect = wintypes.RECT() + rect.left = 0 + rect.top = 0 + rect.right = cx - 2 * margin + rect.bottom = 1000 + user32.DrawTextW( + hdc_static, + c_wchar_p(self.prompt), + -1, + byref(rect), + WIN_DT_WORDBREAK | WIN_DT_CALCRECT | WIN_DT_NOPREFIX, + ) + new_static_h = max(static_h, rect.bottom - rect.top) + if new_static_h != static_h: + # Resize static and move controls below it + user32.SetWindowPos( + self.h_static, + 0, + margin, + margin, + cx - 2 * margin, + new_static_h, + WIN_SWP_NOZORDER, + ) + new_edit_y = margin + new_static_h + 8 + user32.SetWindowPos( + self.h_edit, + 0, + margin, + new_edit_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + new_btn_y = new_edit_y + edit_h + spacing * 2 + user32.SetWindowPos( + self.h_ok, + 0, + ok_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + user32.SetWindowPos( + self.h_cancel, + 0, + cancel_x, + new_btn_y, + 0, + 0, + WIN_SWP_NOSIZE | WIN_SWP_NOZORDER, + ) + if prev2: + gdi32.SelectObject(hdc_static, prev2) + finally: + user32.ReleaseDC(self.h_static, hdc_static) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Applying default GUI font failed: %s", exc) + + # Defaults + if self.options.default_text: + user32.SetWindowTextW(self.h_edit, c_wchar_p(self.options.default_text)) + if self.options.placeholder: + try: + user32.SendMessageW( + self.h_edit, WIN_EM_SETCUEBANNER, 1, c_wchar_p(self.options.placeholder) + ) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Setting placeholder text failed: %s", exc) + if self.options.char_limit is not None: + user32.SendMessageW(self.h_edit, WIN_EM_LIMITTEXT, self.options.char_limit, 0) + + # Initial validation + if self.options.validator is not None: + self._validate_live() + + # Center over owner + try: + owner_hwnd = owner or user32.GetActiveWindow() + if owner_hwnd: + rect = wintypes.RECT() + user32.GetWindowRect(owner_hwnd, byref(rect)) + owner_cx = rect.right - rect.left + owner_cy = rect.bottom - rect.top + wnd_rect = wintypes.RECT() + user32.GetWindowRect(hwnd, byref(wnd_rect)) + x = rect.left + (owner_cx - (wnd_rect.right - wnd_rect.left)) // 2 + y = rect.top + (owner_cy - (wnd_rect.bottom - wnd_rect.top)) // 2 + user32.SetWindowPos( + hwnd, 0, x, y, 0, 0, WIN_SWP_NOSIZE | WIN_SWP_NOZORDER | WIN_SWP_NOACTIVATE + ) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Centering dialog over owner failed: %s", exc) + + user32.ShowWindow(hwnd, 5) # SW_SHOW + try: + user32.UpdateWindow(hwnd) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("UpdateWindow failed: %s", exc) + if self.h_edit: + user32.SetFocus(self.h_edit) + + # Modal loop + self._done = False # type: ignore[attr-defined] + msg = wintypes.MSG() + while not self._done: + ret = user32.GetMessageW(byref(msg), 0, 0, 0) + if ret == 0: # WM_QUIT + break + if ret == -1: + break + # Let the system process default button (Enter), Esc, and Tab order + if not user32.IsDialogMessageW(hwnd, byref(msg)): + user32.TranslateMessage(byref(msg)) + user32.DispatchMessageW(byref(msg)) + + # No owner to restore + # Restore previous SIGINT handler + try: + prev = getattr(self, "_prev_sigint", None) + if prev is not None: + signal.signal(signal.SIGINT, prev) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Restoring SIGINT handler failed: %s", exc) + # Remove native console handler + try: + handler = getattr(self, "_console_ctrl_handler", None) + if handler is not None: + kernel32.SetConsoleCtrlHandler(handler, False) + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Removing console control handler failed: %s", exc) + return self._result_text + + # Helper methods for WNDPROC + def _on_ok(self) -> None: + user32 = windll.user32 + length = user32.GetWindowTextLengthW(self.h_edit) # type: ignore[attr-defined] + buf = create_unicode_buffer(length + 1) + user32.GetWindowTextW(self.h_edit, buf, length + 1) # type: ignore[attr-defined] + self._result_text = buf.value + user32.DestroyWindow(self.hwnd) # type: ignore[attr-defined] + self._done = True # type: ignore[attr-defined] + + def _on_cancel(self) -> None: + user32 = windll.user32 + self._result_text = None + user32.DestroyWindow(self.hwnd) # type: ignore[attr-defined] + self._done = True # type: ignore[attr-defined] + + def _on_destroy(self) -> None: + self._done = True # type: ignore[attr-defined] + + def _on_size(self) -> None: + # Reflow controls on window resize + try: + user32 = windll.user32 + rect = wintypes.RECT() + user32.GetClientRect(self.hwnd, byref(rect)) # type: ignore[attr-defined] + cx = rect.right - rect.left + + margin = getattr(self, "_layout_margin", 24) + spacing = getattr(self, "_layout_spacing", 12) + btn_w = getattr(self, "_layout_btn_w", 88) + btn_h = getattr(self, "_layout_btn_h", 26) + + # Static keeps same height; stretch width + # Measure static height + static_rect = wintypes.RECT() + user32.GetWindowRect(self.h_static, byref(static_rect)) # type: ignore[attr-defined] + static_h = static_rect.bottom - static_rect.top + user32.SetWindowPos(self.h_static, 0, margin, margin, cx - 2 * margin, static_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + + # Edit stretches horizontally, stays below static + edit_y = margin + static_h + 8 + # Preserve current edit height + cur_edit_rect = wintypes.RECT() + user32.GetWindowRect(self.h_edit, byref(cur_edit_rect)) # type: ignore[attr-defined] + cur_edit_h = cur_edit_rect.bottom - cur_edit_rect.top + user32.SetWindowPos(self.h_edit, 0, margin, edit_y, cx - 2 * margin, cur_edit_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + + # Buttons right-aligned + cancel_x = cx - margin - btn_w + ok_x = cancel_x - spacing - btn_w + btn_y = edit_y + cur_edit_h + spacing * 2 + user32.SetWindowPos(self.h_ok, 0, ok_x, btn_y, btn_w, btn_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + user32.SetWindowPos(self.h_cancel, 0, cancel_x, btn_y, btn_w, btn_h, WIN_SWP_NOZORDER) # type: ignore[attr-defined] + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Resize reflow failed: %s", exc) + + def _validate_live(self) -> None: + user32 = windll.user32 + length = user32.GetWindowTextLengthW(self.h_edit) # type: ignore[attr-defined] + buf = create_unicode_buffer(length + 1) + user32.GetWindowTextW(self.h_edit, buf, length + 1) # type: ignore[attr-defined] + try: + is_valid = bool(self.options.validator(buf.value)) if self.options.validator else True + except Exception as exc: + logger = get_logger("message_box") + if logger: + logger.debug("Validator raised exception: %s", exc) + is_valid = True + user32.EnableWindow(self.h_ok, wintypes.BOOL(1 if is_valid else 0)) # type: ignore[attr-defined] diff --git a/tests/api/integration_tests/test_message_box_permutations.py b/tests/api/integration_tests/test_message_box_permutations.py new file mode 100644 index 0000000..a29bddc --- /dev/null +++ b/tests/api/integration_tests/test_message_box_permutations.py @@ -0,0 +1,196 @@ +"""Integration tests for message box permutations (Windows only).""" + +# These tests perform UI automation and include short-lived closures and +# complex helper logic. Suppress a few linter warnings that are noisy for +# this test file and do not improve correctness: +# pylint: disable=cell-var-from-loop,unused-argument,too-many-branches,too-many-statements + +import threading +import time +import ctypes +from ctypes import windll, c_wchar_p, wintypes + +from moldflow import ( + MessageBox, + MessageBoxType, + MessageBoxResult, + MessageBoxOptions, + MessageBoxIcon, + MessageBoxDefaultButton, + MessageBoxModality, +) + +# Win32 constants for automation +WM_COMMAND = 0x0111 +BM_CLICK = 0x00F5 +IDOK = 1 +IDCANCEL = 2 +IDYES = 6 +IDNO = 7 +IDRETRY = 4 + + +def _click_dialog_button_async(dialog_title: str, button_id: int, delay_s: float = 0.4) -> None: + """Helper: simulate clicking a button on a dialog by title after a small delay.""" + + def _worker(): + user32 = windll.user32 + # Wait a moment for the dialog to appear + time.sleep(delay_s) + # Try to find and click for up to ~5 seconds + for _ in range(100): + hwnd = user32.FindWindowW(None, c_wchar_p(dialog_title)) + if hwnd: + # Try to find child button control and click it directly + try: + hbtn = user32.GetDlgItem(hwnd, button_id) + if hbtn: + # Prefer PostMessage to avoid synchronous reentrancy + user32.PostMessageW(hbtn, BM_CLICK, 0, 0) + return + + children = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def _child_enum_proc(hchild, _): + # Get class name + cname_buf = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hchild, cname_buf, 256) + cname = cname_buf.value + # Get text + tbuf = ctypes.create_unicode_buffer(512) + user32.GetWindowTextW(hchild, tbuf, 512) + text = tbuf.value + # Get control id + try: + cid = user32.GetDlgCtrlID(hchild) + except Exception: + cid = 0 + children.append((hchild, cname, text, cid)) + return True + + user32.EnumChildWindows(hwnd, _child_enum_proc, 0) + + # Try to click first Button child + for hchild, cname, _, _ in children: + if cname and cname.lower().startswith("button"): + user32.PostMessageW(hchild, BM_CLICK, 0, 0) + return + + except Exception: + # Fallback to posting WM_COMMAND + try: + user32.PostMessageW(hwnd, WM_COMMAND, button_id, 0) + return + except Exception: + pass + else: + # Fallback: enumerate top-level windows and try to find one whose + # title contains the dialog title as a substring (more tolerant). + try: + found = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def _enum_proc(h, _): + buf = ctypes.create_unicode_buffer(512) + user32.GetWindowTextW(h, buf, 512) + txt = buf.value + if txt and dialog_title in txt: + found.append(h) + return False # stop enumeration + return True + + user32.EnumWindows(_enum_proc, 0) + if found: + hwnd = found[0] + try: + hbtn = user32.GetDlgItem(hwnd, button_id) + if hbtn: + user32.PostMessageW(hbtn, BM_CLICK, 0, 0) + return + except Exception: + try: + user32.PostMessageW(hwnd, WM_COMMAND, button_id, 0) + return + except Exception: + pass + except Exception: + # EnumWindows may fail in some restricted contexts; ignore + pass + time.sleep(0.1) + + threading.Thread(target=_worker, daemon=True).start() + + +def _iter_types_and_defaults(): + """Yield (type, valid default_button flags, button_id_to_click).""" + mapping = { + MessageBoxType.INFO: (1, IDOK), + MessageBoxType.WARNING: (1, IDOK), + MessageBoxType.ERROR: (1, IDOK), + MessageBoxType.OK_CANCEL: (2, IDCANCEL), + MessageBoxType.YES_NO: (2, IDYES), + MessageBoxType.RETRY_CANCEL: (2, IDCANCEL), + MessageBoxType.YES_NO_CANCEL: (3, IDYES), + MessageBoxType.ABORT_RETRY_IGNORE: (3, IDRETRY), + MessageBoxType.CANCEL_TRY_CONTINUE: (3, IDCANCEL), + } + for t, (count, click_id) in mapping.items(): + defaults = [None] + if count >= 2: + defaults.append(MessageBoxDefaultButton.BUTTON2) + if count >= 3: + defaults.append(MessageBoxDefaultButton.BUTTON3) + if count >= 4: + defaults.append(MessageBoxDefaultButton.BUTTON4) + yield t, defaults, click_id + + +def test_message_box_permutations(): + """Exercise combinations of types, icons, default buttons and modality.""" + + icons = [ + None, + MessageBoxIcon.INFORMATION, + MessageBoxIcon.WARNING, + MessageBoxIcon.ERROR, + MessageBoxIcon.QUESTION, + ] + modalities = [None, MessageBoxModality.TASK, MessageBoxModality.SYSTEM] + + for box_type, default_buttons, click_id in _iter_types_and_defaults(): + for icon in icons: + for default_button in default_buttons: + for modality in modalities: + opts = MessageBoxOptions( + icon=icon, default_button=default_button, modality=modality + ) + title = f"Test: {box_type.name}" + # Auto click to allow unattended run + _click_dialog_button_async(title, click_id) + msg = ( + f"{box_type.name} - {getattr(icon, 'name', 'NONE')} - " + f"{getattr(default_button, 'name', 'BUTTON1')} - " + f"{getattr(modality, 'name', 'APPLICATION')}" + ) + result = MessageBox(msg, box_type, title=title, options=opts).show() + assert isinstance(result, MessageBoxResult) + + +def test_message_box_input_variants(): + """ + Exercise input dialog with various options. + """ + variants = [ + MessageBoxOptions(default_text="auto"), + MessageBoxOptions(default_text="auto", is_password=True), + MessageBoxOptions(default_text="auto", char_limit=10), + MessageBoxOptions(default_text="auto", width_dlu=280, height_dlu=90), + ] + for i, opts in enumerate(variants, 1): + title = f"Test: INPUT #{i}" + _click_dialog_button_async(title, IDOK) + value = MessageBox( + "Enter sample text", MessageBoxType.INPUT, title=title, options=opts + ).show() + assert isinstance(value, (str, type(None))) From d81f92056889af112406f56cc2733c1cee469e62 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:34:01 +1100 Subject: [PATCH 03/14] Allow custom release locations in run.py (#19) * allow custom release locations in run.py * more robust --- run.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/run.py b/run.py index 61a32db..ffcc814 100644 --- a/run.py +++ b/run.py @@ -12,8 +12,8 @@ run.py install [-s | --skip-build] run.py install-package-requirements run.py lint [-s | --skip-build] - run.py publish [-s | --skip-build] [--testpypi] - run.py release + run.py publish [-s | --skip-build] [(--testpypi | --repo-url=)] + run.py release [--github-api-url=] run.py report [-c | --cli] [-h | --html] [-x | --xml] run.py test [...] [-m | --marker=] [-s | --skip-build] [-k | --keep-files] [-q | --quiet] [--unit] [--integration] [--core] [--all] @@ -49,6 +49,8 @@ --unit Run only unit tests. --integration Run only integration tests. --all Run all tests. + --repo-url= Custom PyPI repository URL. + --github-api-url= Custom GitHub API URL. """ import os @@ -58,6 +60,8 @@ import platform import subprocess import shutil +import glob +from urllib.parse import urlparse import docopt from github import Github @@ -148,7 +152,7 @@ def build_package(install=True): install_package() -def publish(skip_build=False, testpypi=False): +def publish(skip_build=False, testpypi=False, repo_url=None): """Publish package""" # Restrict publishing to GitHub Actions workflow @@ -157,6 +161,9 @@ def publish(skip_build=False, testpypi=False): 'Publishing to PyPI is restricted to the GitHub Actions manual workflow.' 'Please use the "Publish to PyPI (manual)" workflow.' ) + # Defensive check: don't allow both testpypi and explicit repo_url + if testpypi and repo_url: + raise RuntimeError('Options testpypi and repo_url are mutually exclusive.') if not skip_build: build_package() @@ -166,14 +173,35 @@ def publish(skip_build=False, testpypi=False): # First check the package logging.info('Checking package validity') - run_command([sys.executable] + f'-m twine check --strict {DIST_FILES}'.split(' '), ROOT_DIR) + # Expand distribution files using glob to avoid shell globbing + dist_files_list = glob.glob(DIST_FILES) + if not dist_files_list: + raise RuntimeError(f'No distribution files found in {DIST_DIR}') + + # Use explicit args to avoid shell interpolation for the check step + run_command([sys.executable, '-m', 'twine', 'check', '--strict'] + dist_files_list, ROOT_DIR) logging.info('Package is valid') - twine_args = '--repository testpypi --verbose' if testpypi else '--verbose' + # Validate repo URL if provided + if repo_url: + parsed = urlparse(repo_url) + # Only allow secure HTTPS URLs for repository uploads + if parsed.scheme != 'https' or not parsed.netloc: + raise RuntimeError(f'Invalid repository URL: {repo_url}. Only HTTPS URLs are allowed.') + + # Build twine upload args safely as a list + twine_cmd = [sys.executable, '-m', 'twine', 'upload', '--verbose'] + + if repo_url: + twine_cmd += ['--repository-url', repo_url] + elif testpypi: + twine_cmd += ['--repository', 'testpypi'] + + twine_cmd += dist_files_list run_command( - [sys.executable] + f'-m twine upload {twine_args} {DIST_FILES}'.split(' '), + twine_cmd, ROOT_DIR, { 'TWINE_USERNAME': os.environ.get('TWINE_USERNAME', ''), @@ -182,7 +210,7 @@ def publish(skip_build=False, testpypi=False): ) -def create_release(): +def create_release(github_api_url=None): """ Create Git tag and GitHub release via PyGithub. @@ -211,7 +239,13 @@ def create_release(): release_name = tag logging.info('Creating GitHub release %s on %s', tag, owner_repo) - gh = Github(token) + + if github_api_url: + logging.info('Using custom GitHub API URL: %s', github_api_url) + gh = Github(token, base_url=github_api_url) + else: + gh = Github(token) + repo = gh.get_repo(owner_repo) release = repo.create_git_tag_and_release( @@ -648,12 +682,22 @@ def main(): elif args.get('publish'): skip_build = args.get('--skip-build') or args.get('-s') - testpypi = args.get('--testpypi') or args.get('-t') + testpypi = args.get('--testpypi') + repo_url = args.get('--repo-url') + + # Docopt groups are XOR, but validate explicitly and present a + # user-friendly message rather than a stack trace. + if testpypi and repo_url: + logging.error( + 'Options --testpypi and --repo-url are mutually exclusive. Provide only one.' + ) + return 2 - publish(skip_build=skip_build, testpypi=testpypi) + publish(skip_build=skip_build, testpypi=testpypi, repo_url=repo_url) elif args.get('release'): - create_release() + github_api_url = args.get('--github-api-url') + create_release(github_api_url=github_api_url) elif args.get('clean-up'): clean_up() From 46498e61175d84bf73c7cc03c698ce42a0067485 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:37:44 +1100 Subject: [PATCH 04/14] Improve MessageBox documentation (#21) * progress * order * allow ok for localisation * add integration test * format * lint * fix test * working * lint * i18n * format * lint * fnish lint * review * placement * omit test coverage for win32 ui heavy class * add to changelog * fix coverage * more friendly win32 error handling * Update src/moldflow/message_box.py Co-authored-by: Sankalp Shrivastava * better doc --------- Co-authored-by: Sankalp Shrivastava --- docs/source/components/wrapper/message_box.rst | 14 +++++++------- src/moldflow/message_box.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/source/components/wrapper/message_box.rst b/docs/source/components/wrapper/message_box.rst index 6fbe4b4..7644b3c 100644 --- a/docs/source/components/wrapper/message_box.rst +++ b/docs/source/components/wrapper/message_box.rst @@ -95,19 +95,19 @@ Options - Application (default), Task-modal, System-modal * - topmost - bool - - Keep message box on top + - Keep message box on top (standard MessageBox only) * - set_foreground - bool - - Force foreground + - Force foreground (standard MessageBox only) * - right_align / rtl_reading - bool - - Layout flags for right-to-left locales + - Layout flags for right-to-left locales (standard MessageBox only) * - help_button - bool - Show Help button * - owner_hwnd - int | None - - Owner window handle (improves modality/Z-order) + - Owner window handle (standard MessageBox only, improves modality/Z-order) * - default_text / placeholder - str | None - Prefill text and cue banner for input dialog @@ -119,13 +119,13 @@ Options - Maximum characters accepted (client-side) * - width_dlu / height_dlu - int | None - - Size the input dialog (dialog units) + - Size the input dialog (pixels; DLUs in legacy template path) * - validator - Callable[[str], bool] | None - Enable OK only when input satisfies predicate * - font_face / font_size_pt - str / int - - Font for input dialog (default Segoe UI 9pt) + - Font for legacy template; CreateWindowEx path uses system dialog font API --- @@ -135,5 +135,5 @@ API Notes ----- -- Localization: button captions ("OK", "Cancel"), title, and prompt are localized via the package i18n system. +- Localization: action button captions (e.g., "OK", "Cancel", "Submit") are localized via the package i18n system. Title and prompt are not localized automatically. - Return type: ``MessageBox.show()`` returns ``MessageBoxReturn`` (``MessageBoxResult | str | None``). diff --git a/src/moldflow/message_box.py b/src/moldflow/message_box.py index 54649ef..0817a51 100644 --- a/src/moldflow/message_box.py +++ b/src/moldflow/message_box.py @@ -359,6 +359,7 @@ class MessageBox: Example: .. code-block:: python + from moldflow import MessageBox, MessageBoxType # Information message From 10b9b158885f9db87d3194016b162937bf650f9f Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:38:15 +1100 Subject: [PATCH 05/14] Update version.json (#20) --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 9e2172b..31ed73a 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "major": "26", + "major": "27", "minor": "0", "patch": "1" } From 6fa74062dfa7341f196111e338130eb1bd0fb2f5 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:59:51 +1100 Subject: [PATCH 06/14] Improve examples in docs (#16) * Fix return types and color band range (#5) * fix types and remove non-existing function * fix tests * fix color band range * increment patch number * fix range and tests * changelog * bring back function * more tests * missing include * typo * better docstrings * correctness * ws * docs only publish * more examples * point to pypi (#3) * remove multi-statement lines * remove vb references * better explanation * simpler * cleanup and make sure the scripts work * Update readme.rst * remove unnecessary fallback --- .github/workflows/publish.yml | 22 +- docs/source/readme.rst | 442 +++++++++++++++++++++++++++++++++- 2 files changed, 446 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad6cd11..18a5e7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,11 @@ on: description: "Confirm publish package" required: true type: boolean + docs_only: + description: "Only publish documentation (skip package publish)" + required: false + type: boolean + default: false jobs: guard-ci-success: @@ -77,9 +82,16 @@ jobs: with: python-version: '3.13' + - name: Install dependencies for docs + if: ${{ github.event.inputs.docs_only == 'true' }} + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Check if version exists on PyPI id: pypi_check shell: pwsh + if: ${{ github.event.inputs.docs_only != 'true' }} run: | $packageName = 'moldflow' @@ -100,22 +112,22 @@ jobs: "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - name: Skip publish, version already exists - if: steps.pypi_check.outputs.exists == 'true' + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'true' }} run: Write-Output "Version ${{ steps.pypi_check.outputs.version }} already exists on PyPI. Skipping publish." - name: Install build dependencies - if: steps.pypi_check.outputs.exists == 'false' + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Build package - if: steps.pypi_check.outputs.exists == 'false' + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} run: | python run.py build - name: Publish to PyPI - if: steps.pypi_check.outputs.exists == 'false' + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} @@ -123,7 +135,7 @@ jobs: python run.py publish --skip-build - name: Create GitHub Release - if: steps.pypi_check.outputs.exists == 'false' + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} run: | python run.py release env: diff --git a/docs/source/readme.rst b/docs/source/readme.rst index a6e47b9..8abbc6c 100644 --- a/docs/source/readme.rst +++ b/docs/source/readme.rst @@ -480,6 +480,212 @@ Iterate Plots and Export Overlays More Advanced Examples ====================== +Derived results from existing plots (absolute temperature) +---------------------------------------------------------- + +.. code-block:: python + + from moldflow import Synergy + from moldflow.common import UserPlotType, TransformScalarOperations + + synergy = Synergy() + pm = synergy.plot_manager + dt = synergy.data_transform + + # Absolute temperature:K = Bulk temperature:13 + 273 + ds_id = pm.find_dataset_id_by_name("Bulk temperature") + all_times = synergy.create_double_array() + pm.get_indp_values(ds_id, all_times) + target_time = 13.0 + closest_time = min(all_times.to_list(), key=lambda t: abs(t - target_time)) + indp = synergy.create_double_array() + indp.add_double(closest_time) + + ent = synergy.create_integer_array() + vals = synergy.create_double_array() + pm.get_scalar_data(ds_id, indp, ent, vals) + + dt.scalar(ent, vals, TransformScalarOperations.ADD, 273.0, ent, vals) + + up = pm.create_user_plot() + up.set_name("Absolute Temperature") + up.set_data_type(UserPlotType.ELEMENT_DATA) + up.set_dept_unit_name("K") + up.set_scalar_data(ent, vals) + up.build() + +Vector difference with absolute value +------------------------------------- + +.. code-block:: python + + from moldflow import Synergy + from moldflow.common import TransformOperations, TransformFunctions, UserPlotType + + synergy = Synergy() + pm = synergy.plot_manager + dt = synergy.data_transform + + name = "Average velocity" # 3-component vector + ds_id = pm.find_dataset_id_by_name(name) + + times = synergy.create_double_array() + pm.get_indp_values(ds_id, times) + t1, t2 = 1.1, 2.0 + c1 = min(times.to_list(), key=lambda t: abs(t - t1)) + c2 = min(times.to_list(), key=lambda t: abs(t - t2)) + + def get_vec_at(t): + indp = synergy.create_double_array() + indp.add_double(t) + ent = synergy.create_integer_array() + vx = synergy.create_double_array() + vy = synergy.create_double_array() + vz = synergy.create_double_array() + pm.get_vector_data(ds_id, indp, ent, vx, vy, vz) + return ent, (vx, vy, vz) + + ent1, (v1x, v1y, v1z) = get_vec_at(c1) + ent2, (v2x, v2y, v2z) = get_vec_at(c2) + + vdx = synergy.create_double_array() + vdy = synergy.create_double_array() + vdz = synergy.create_double_array() + for a, b, out in [(v1x, v2x, vdx), (v1y, v2y, vdy), (v1z, v2z, vdz)]: + dt.op(ent1, a, TransformOperations.SUBTRACT, ent2, b, ent1, out) + dt.func(TransformFunctions.ABSOLUTE, ent1, out, ent1, out) + + up = pm.create_user_plot() + up.set_name("Difference in Velocity") + up.set_data_type(UserPlotType.ELEMENT_DATA) + up.set_vector_as_displacement(False) + up.set_dept_unit_name("m/s") + up.set_vector_data(ent1, vdx, vdy, vdz) + up.build() + +Operate on current plot +----------------------- + +.. code-block:: python + + from moldflow import Synergy + from moldflow.common import TransformFunctions, UserPlotType + + synergy = Synergy() + pm = synergy.plot_manager + viewer = synergy.viewer + dt = synergy.data_transform + + active = viewer.active_plot + if active is None: + raise RuntimeError("No active plot") + ds_id = active.data_id + + cols = pm.get_data_nb_components(ds_id) + dtype = pm.get_data_type(ds_id) + + times = synergy.create_double_array() + pm.get_indp_values(ds_id, times) + indp = None + if times.size > 0: + indp = synergy.create_double_array() + indp.add_double(times.to_list()[0]) + + ent = synergy.create_integer_array() + arrs = [synergy.create_double_array() for _ in range(max(cols, 1))] + + if cols == 1: + pm.get_scalar_data(ds_id, indp, ent, arrs[0]) + elif cols == 3: + pm.get_vector_data(ds_id, indp, ent, arrs[0], arrs[1], arrs[2]) + elif cols == 6: + pm.get_tensor_data(ds_id, indp, ent, *arrs) + else: + raise RuntimeError("Unsupported component count") + + for a in arrs[:cols]: + dt.func(TransformFunctions.ABSOLUTE, ent, a, ent, a) + + up = pm.create_user_plot() + up.set_name("ABS of current plot") + up.set_data_type(UserPlotType.ELEMENT_DATA if dtype == "ELDT" else UserPlotType.NODE_DATA) + up.set_vector_as_displacement(dtype == "NDDT") + if cols == 1: + up.set_scalar_data(ent, arrs[0]) + elif cols == 3: + up.set_vector_data(ent, arrs[0], arrs[1], arrs[2]) + elif cols == 6: + up.set_tensor_data(ent, *arrs[:6]) + up.build() + +Normalize by max at a time +-------------------------- + +.. code-block:: python + + from moldflow import Synergy + from moldflow.common import TransformScalarOperations, UserPlotType + + synergy = Synergy() + pm = synergy.plot_manager + dt = synergy.data_transform + + name = "Pressure" + ds_id = pm.find_dataset_id_by_name(name) + + times = synergy.create_double_array() + pm.get_indp_values(ds_id, times) + target_time = 3.5 + closest = min(times.to_list(), key=lambda t: abs(t - target_time)) + indp = synergy.create_double_array() + indp.add_double(closest) + + ent = synergy.create_integer_array() + vals = synergy.create_double_array() + pm.get_scalar_data(ds_id, indp, ent, vals) + + max_v = max(vals.to_list()) + out = synergy.create_double_array() + dt.scalar(ent, vals, TransformScalarOperations.DIVIDE, max_v, ent, out) + + up = pm.create_user_plot() + up.set_name("Pre/Max") + up.set_data_type(UserPlotType.ELEMENT_DATA) + up.set_dept_unit_name("%") + up.set_scalar_data(ent, out) + up.build() + +Average/min/max diagnostics to console +-------------------------------------- + +.. code-block:: python + + from moldflow import Synergy + + synergy = Synergy() + diag = synergy.diagnosis_manager + + ent = synergy.create_integer_array() + vals = synergy.create_double_array() + count = diag.get_aspect_ratio_diagnosis(0.0, 0.0, True, False, ent, vals) + if count > 0: + data = vals.to_list() + ave = sum(data) / len(data) + print(f"Average aspect ratio: {ave:.3f}") + +Entity selection strings +------------------------ + +.. code-block:: python + + from moldflow import Synergy + + synergy = Synergy() + el = synergy.study_doc.create_entity_list() + el.select_from_string("N1:2 N5 N8:9") + print(el.size) + print(el.convert_to_string()) + Configure Import Options Before CAD Import ------------------------------------------ @@ -622,9 +828,12 @@ Data Transform synergy = Synergy() dt = synergy.data_transform - ia = synergy.create_integer_array(); ia.from_list([1, 2, 3]) - da = synergy.create_double_array(); da.from_list([0.5, 1.5, 2.5]) - ib = synergy.create_integer_array(); db = synergy.create_double_array() + ia = synergy.create_integer_array() + ia.from_list([1, 2, 3]) + da = synergy.create_double_array() + da.from_list([0.5, 1.5, 2.5]) + ib = synergy.create_integer_array() + db = synergy.create_double_array() ok = dt.scalar(ia, da, TransformScalarOperations.MULTIPLY, 2.0, ib, db) print(f"Scalar transform ok: {ok}") @@ -715,7 +924,8 @@ Modeler synergy = Synergy() modeler = synergy.modeler - v = synergy.create_vector(); v.set_xyz(0.0, 0.0, 0.0) + v = synergy.create_vector() + v.set_xyz(0.0, 0.0, 0.0) node_list = modeler.create_node_by_xyz(v) print("Created node list") @@ -729,7 +939,8 @@ Mold Surface Generator synergy = Synergy() msg = synergy.mold_surface_generator msg.centered = True - dim = synergy.create_vector(); dim.set_xyz(100.0, 80.0, 60.0) + dim = synergy.create_vector() + dim.set_xyz(100.0, 80.0, 60.0) msg.dimensions = dim ok = msg.generate() print(f"Mold surface generated: {ok}") @@ -755,11 +966,209 @@ Runner Generator synergy = Synergy() rg = synergy.runner_generator - rg.sprue_x = 0.0; rg.sprue_y = 0.0; rg.sprue_length = 30.0 - rg.sprue_diameter = 6.0; rg.sprue_taper_angle = 2.0 + rg.sprue_x = 0.0 + rg.sprue_y = 0.0 + rg.sprue_length = 30.0 + rg.sprue_diameter = 6.0 + rg.sprue_taper_angle = 2.0 ok = rg.generate() print(f"Runner generated: {ok}") +Build PowerPoint Report (using python-pptx) +------------------------------------------- + +This example emulates a report workflow directly from Python, using python-pptx to author a PPTX. See python-pptx on PyPI: https://pypi.org/project/python-pptx + +.. code-block:: python + + import os + import tempfile + from moldflow import Synergy + from moldflow.common import StandardViews + + # pip install python-pptx + from pptx import Presentation + from pptx.util import Inches, Pt + + synergy = Synergy() + sd = synergy.study_doc + pm = synergy.plot_manager + viewer = synergy.viewer + project = synergy.project + + # Ensure a visible viewport for captures + viewer.set_view_size(1600, 900) + + # Result overlay flags + PLOT_RESULT = True + PLOT_LEGEND = True + PLOT_AXIS = True + PLOT_ROTATION = True + PLOT_SCALE_BAR = True + PLOT_PLOT_INFO = True + PLOT_STUDY_TITLE = True + PLOT_RULER = True + PLOT_LOGO = True + + def capture_plot_image(plot, orientation: str | None, width_px=1600, height_px=900) -> str: + # Apply orientation and fit the view + if orientation and orientation.upper() != "CURRENT": + target = orientation.title() if isinstance(orientation, str) else orientation + viewer.go_to_standard_view(target) + # Show plot (if provided) or ensure geometry-only capture + if plot is not None: + viewer.show_plot(plot) + # Ensure plot is generated and a valid frame is visible + try: + plot.regenerate() + except Exception: + pass + try: + # Show the last frame if available + frames = viewer.get_number_frames_by_name(plot.name) + if isinstance(frames, int) and frames > 0: + viewer.show_plot_frame(plot, frames - 1) + except Exception: + pass + else: + # Hide any active plot to avoid empty result layer when capturing geometry + ap = viewer.active_plot + if ap is not None: + viewer.hide_plot(ap) + viewer.fit() + + tmp = tempfile.NamedTemporaryFile(prefix="mf_plot_", suffix=".png", delete=False) + tmp.close() + + # Use full overlay flags when capturing results; turn them off for geometry-only + if plot is not None: + viewer.save_image( + tmp.name, + x=width_px, + y=height_px, + result=PLOT_RESULT, + legend=PLOT_LEGEND, + axis=PLOT_AXIS, + rotation=PLOT_ROTATION, + scale_bar=PLOT_SCALE_BAR, + plot_info=PLOT_PLOT_INFO, + study_title=PLOT_STUDY_TITLE, + ruler=PLOT_RULER, + logo=PLOT_LOGO, + ) + else: + viewer.save_image(tmp.name, x=width_px, y=height_px, result=False, legend=False, axis=False) + + return tmp.name + + prs = Presentation() + title_layout = prs.slide_layouts[0] + title_only_layout = prs.slide_layouts[5] + + def add_title(slide, text: str): + if slide.shapes.title is not None: + slide.shapes.title.text = text + slide.shapes.title.text_frame.paragraphs[0].font.size = Pt(28) + + def add_picture_centered(slide, image_path: str, max_width_in=10.0, top_in=1.5): + pic = slide.shapes.add_picture(image_path, Inches(0), Inches(top_in)) + page_w = prs.slide_width + max_w = Inches(max_width_in) + if pic.width > max_w: + scale = max_w / pic.width + pic.width = int(pic.width * scale) + pic.height = int(pic.height * scale) + pic.left = int((page_w - pic.width) / 2) + return pic + + slide = prs.slides.add_slide(title_layout) + add_title(slide, f"Report: {sd.study_name}") + if slide.placeholders and len(slide.placeholders) > 1: + slide.placeholders[1].text = f"Project: {project.path}" + + geo_slide = prs.slides.add_slide(title_only_layout) + add_title(geo_slide, "Geometry Overview") + viewer.reset() + viewer.go_to_standard_view(StandardViews.ISOMETRIC) + viewer.fit() + geo_img = capture_plot_image(plot=None, orientation="CURRENT") + add_picture_centered(geo_slide, geo_img) + + diag = synergy.diagnosis_manager + summary = diag.get_mesh_summary(element_only=False) + mesh_slide = prs.slides.add_slide(title_only_layout) + add_title(mesh_slide, "Mesh Summary") + tf = mesh_slide.shapes.add_textbox(Inches(1), Inches(1.5), Inches(8), Inches(3)).text_frame + tf.word_wrap = True + lines = [ + f"Nodes: {summary.nodes_count}", + f"Triangles: {summary.triangles_count}", + f"Tetras: {summary.tetras_count}", + f"Beams: {summary.beams_count}", + f"AspectRatio avg/min/max: {summary.ave_aspect_ratio:.3f} / {summary.min_aspect_ratio:.3f} / {summary.max_aspect_ratio:.3f}", + ] + for i, line in enumerate(lines): + p = tf.add_paragraph() if i else tf.paragraphs[0] + p.text = line + p.font.size = Pt(16) + + def add_plot_slide(plot_obj, title: str, orientation: str = "ISOMETRIC"): + slide = prs.slides.add_slide(title_only_layout) + add_title(slide, title) + img = capture_plot_image(plot_obj, orientation) + if img: + add_picture_centered(slide, img) + try: + os.remove(img) + except Exception: + pass + else: + tx = slide.shapes.add_textbox(Inches(1), Inches(1.5), Inches(8), Inches(1.0)).text_frame + tx.text = "Skipped non-mesh/unsupported plot for 3D capture" + tx.paragraphs[0].font.size = Pt(14) + + def add_plot_four_views(plot_obj, title: str): + slide = prs.slides.add_slide(title_only_layout) + add_title(slide, f"{title} - Four Views") + views = ["ISOMETRIC", "FRONT", "LEFT", "TOP"] + cols = 2 + x0, y0 = Inches(0.7), Inches(1.3) + cell_w, cell_h = Inches(4.5), Inches(3.2) + for idx, v in enumerate(views): + img = capture_plot_image(plot_obj, v, width_px=1000, height_px=600) + col = idx % cols + row = idx // cols + left = x0 + col * (cell_w + Inches(0.2)) + top = y0 + row * (cell_h + Inches(0.2)) + if img: + pic = slide.shapes.add_picture(img, left, top) + if pic.width > cell_w: + scale = cell_w / pic.width + pic.width = int(pic.width * scale) + pic.height = int(pic.height * scale) + if pic.height > cell_h: + scale = cell_h / pic.height + pic.width = int(pic.width * scale) + pic.height = int(pic.height * scale) + try: + os.remove(img) + except Exception: + pass + + plot = pm.get_first_plot() + count = 0 + while plot: + name = plot.name + add_plot_slide(plot, name, orientation="ISOMETRIC") + if count < 2: + add_plot_four_views(plot, name) + plot = pm.get_next_plot(plot) + count += 1 + + out_path = os.path.join(project.path, f"{os.path.splitext(sd.study_name)[0]}_report.pptx") + prs.save(out_path) + print(f"Report saved: {out_path}") + Study Document -------------- @@ -783,8 +1192,10 @@ System Messages synergy = Synergy() sm = synergy.system_message - sa = synergy.create_string_array(); sa.from_list(["Diameter", "Length"]) - da = synergy.create_double_array(); da.from_list([6.0, 30.0]) + sa = synergy.create_string_array() + sa.from_list(["Diameter", "Length"]) + da = synergy.create_double_array() + da.from_list([6.0, 30.0]) msg = sm.get_data_message(100, sa, da, SystemUnits.METRIC) print(msg) @@ -796,10 +1207,15 @@ Arrays and Geometry Helpers from moldflow import Synergy synergy = Synergy() - ia = synergy.create_integer_array(); ia.from_list([1, 2, 3]); print(ia.size) - da = synergy.create_double_array(); da.from_list([0.1, 0.2]) - sa = synergy.create_string_array(); sa.from_list(["A", "B"]) - vec = synergy.create_vector(); vec.set_xyz(1.0, 2.0, 3.0) + ia = synergy.create_integer_array() + ia.from_list([1, 2, 3]) + print(ia.size) + da = synergy.create_double_array() + da.from_list([0.1, 0.2]) + sa = synergy.create_string_array() + sa.from_list(["A", "B"]) + vec = synergy.create_vector() + vec.set_xyz(1.0, 2.0, 3.0) va = synergy.create_vector_array() API Reference From c8ed93ade75428ad0d653ded148aefd019f62b03 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:04:10 +1100 Subject: [PATCH 07/14] Put version back to 27.0.0 as we havent produced a wheel with that version yet. (#22) --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 31ed73a..57c7a4d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "major": "27", "minor": "0", - "patch": "1" + "patch": "0" } From 3137a7019bc152262d973d71c639abfa50d76bdb Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:05:30 +1100 Subject: [PATCH 08/14] Fix links in README (#27) * fix links * changelog --- CHANGELOG.md | 1 + README.md | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fee691..e26bd67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added convenience class for showing message boxes and text input dialogs via Win32 +- Fixed README links ### Changed - N/A diff --git a/README.md b/README.md index 4632ecf..349d83d 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ python run.py build-docs Options: - `--skip-build` (`-s`): Skip building before generating docs -The documentation can be accessed locally by opening the [index.html](docs/build/html/index.html) in the [html](docs/build/html/) folder. +The documentation can be accessed locally by opening the index.html in the docs/build/html/ folder. ### Running the Formatter @@ -145,7 +145,7 @@ Key modules include: ## Contributing -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to contribute to this project. Here's a quick overview: +We welcome contributions! Please see our [Contributing Guide](https://github.com/Autodesk/moldflow-api/blob/main/CONTRIBUTING.md) for details on how to contribute to this project. Here's a quick overview: 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) @@ -161,15 +161,14 @@ We use [Semantic Versioning](https://semver.org/). For available versions, see t ## License -This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. +This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/Autodesk/moldflow-api/blob/main/LICENSE) file for details. ## Support - **Documentation**: [Full documentation available online](https://autodesk.github.io/moldflow-api) - **Issues**: Report bugs and request features through [GitHub Issues](https://github.com/Autodesk/moldflow-api/issues) -- **Security**: For security issues, please see our [Security Policy](SECURITY.md) -- **Discussions**: Join our [GitHub Discussions](https://github.com/Autodesk/moldflow-api/discussions) for questions and community support +- **Security**: For security issues, please see our [Security Policy](https://github.com/Autodesk/moldflow-api/blob/main/SECURITY.md) ## Code of Conduct -This project adheres to the Contributor Covenant [code of conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. +This project adheres to the Contributor Covenant [code of conduct](https://github.com/Autodesk/moldflow-api/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. From 16e6e02603382a0ac5cd0248ac730a0ddc0d2bc8 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:08:00 +1100 Subject: [PATCH 09/14] clarify release strategy (#23) --- .github/workflows/publish.yml | 3 +- RELEASE.md | 109 ++++++++++++++++++++++++++++++++++ run.py | 6 +- 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 RELEASE.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 18a5e7c..f85fca2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -104,8 +104,7 @@ jobs: } $versionJson = Get-Content -Raw -Path version.json | ConvertFrom-Json - $patch = if ($env:BUILD_NUMBER) { $env:BUILD_NUMBER } else { "$($versionJson.patch)" } - $version = "$($versionJson.major).$($versionJson.minor).$patch" + $version = "$($versionJson.major).$($versionJson.minor).$($versionJson.patch)" $exists = if ($versions -contains $version) { 'true' } else { 'false' } Write-Output "Release $version exists on PyPI: $exists" "exists=$exists" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..10f0b0b --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,109 @@ +# Release steps (minimal) + +This file documents the minimal, explicit steps to bump the version and publish a release for moldflow-api. + +Summary (short): + +- Edit the root `version.json` (only this file). +- Commit on a branch named `release/MAJOR.MINOR.PATCH` and push. +- Wait for CI to pass on that branch. +- Trigger the manual "Publish package (manual)" workflow in GitHub Actions and confirm. + +Why this works (important notes): + +- The canonical version source for releases is the root `version.json` in the repository root. +- The `run.py` script (used for building and releasing locally and by many repo commands) + reads the `patch` value directly from the root `version.json` and will raise a + RuntimeError if `patch` is missing. +- The workflow only publishes when run on a branch whose name starts with `release/` and when + you confirm the manual workflow dispatch. +- The release workflow checks PyPI for an existing version and will skip publishing if that + exact version already exists. + +Minimal step-by-step + +1. Decide the new major/minor/patch values. + + - Always set a numeric `patch` value in the root `version.json`. + +2. Edit `version.json` at the repository root. Example (bumping to MAJOR.MINOR.PATCH, e.g. 1.2.0): + +```json +{ + "major": "27", + "minor": "0", + "patch": "0" +} +``` + +3. Commit and push on a `release/` branch. Example (use the placeholder branch name below and replace with your version): + +```bash +# create branch using the target version (branch name must start with 'release/') +# use a placeholder like 'release/MAJOR.MINOR.PATCH' and replace with your values +git checkout -b release/MAJOR.MINOR.PATCH # e.g. release/1.2.0 +git add version.json +git commit -m "Bump version to MAJOR.MINOR.PATCH" # e.g. "Bump version to 1.2.0" +git push -u origin release/MAJOR.MINOR.PATCH +``` + +4. Wait for CI to pass on that branch. + + - The publish workflow has a guard that requires the `ci.yml` workflow to have completed + successfully for the same commit before allowing publish. + +5. Trigger the publish workflow manually in the GitHub Actions UI for the repository. + + - Open the `Publish package (manual)` workflow, choose `Run workflow`, set `confirm` to + `true`, and run it on your `release/MAJOR.MINOR.PATCH` branch (replace the placeholder + with the actual version). + - Alternatively, you can use the GitHub CLI (if you have it configured). Example using a + placeholder branch name (replace with your actual branch): +```bash +# example (replace with the correct workflow file name and branch if needed) +# gh workflow run publish.yml --ref release/MAJOR.MINOR.PATCH -f confirm=true +# e.g. --ref release/1.2.0 +``` + +6. What the workflow does (high level): + +- Ensures CI (`ci.yml`) passed for the commit. +- Computes the release version using `version.json`. +- If the computed version already exists on PyPI the workflow will skip the publish. +- If not present, it builds the package, uploads to PyPI (requires the repo to have + `PYPI_API_TOKEN` in secrets), creates a GitHub release (tag `vMAJOR.MINOR.PATCH`) and + deploys documentation to GitHub Pages. + +Local testing and notes + +- You can build the package locally to smoke test the build step: + +```bash +python run.py build +``` + +- Publishing to PyPI is intentionally restricted to the manual GitHub Actions workflow. + If you need to test publishing to TestPyPI locally, you can use `python -m twine upload` + with TestPyPI credentials, but this is separate from the CI-based publish flow. + +Edge cases and tips + +- `run.py` now requires a `patch` value in the root `version.json`. It will raise a + RuntimeError if that key is missing. Do not rely on `run.py` falling back to any + environment variable. +- If you want CI to inject a monotonic build number into the patch segment, make the CI + step explicitly update `version.json` (or generate a temporary `version.json`) with the + desired `patch` before running the build and publish steps. That keeps `run.py` and the + workflow in agreement. +- The `run.py` script will write a package-local `src/moldflow/version.json` at build time; + you do not need to edit that file directly (it is generated and typically ignored by Git). + +Cleanup (optional) + +- After a successful release you may merge the `release/` branch to `main` (if you use merge + workflow) and delete the `release/` branch. + +Contact + +If anything in CI behaves unexpectedly, check the logs for the `publish` workflow and the +`ci` workflow; feel free to open an issue or ask a maintainer. diff --git a/run.py b/run.py index ffcc814..5c25149 100644 --- a/run.py +++ b/run.py @@ -73,7 +73,6 @@ SITE_PACKAGES = 'moldflow-site-packages' VERSION_JSON = 'version.json' VERSION = '' -BUILD_NUMBER = os.environ.get('BUILD_NUMBER', '0') # FILE TYPES PY_FILES_EXT = '.py' @@ -588,14 +587,13 @@ def set_version(): # Set global version for use in other functions global VERSION - patch_value = vers_json_dict.get('patch', BUILD_NUMBER) - VERSION = f"{vers_json_dict['major']}.{vers_json_dict['minor']}.{patch_value}" + VERSION = f"{vers_json_dict['major']}.{vers_json_dict['minor']}.{vers_json_dict['patch']}" # Create package version.json with complete version info package_version = { "major": vers_json_dict['major'], "minor": vers_json_dict['minor'], - "patch": patch_value, + "patch": vers_json_dict['patch'], } # Ensure package directory exists From 75636eced0623b33a67119955ad0f8338f452d90 Mon Sep 17 00:00:00 2001 From: osinjoku <49887472+osinjoku@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:44:41 +1100 Subject: [PATCH 10/14] Bring back release changes (#29) * Fix return types and color band range (#5) * fix types and remove non-existing function * fix tests * fix color band range * increment patch number * fix range and tests * changelog * bring back function * more tests * missing include * typo * better docstrings * correctness * ws * point to pypi (#3) * Bring relevant changes to the v26 release branch (#24) * Improve examples in docs (#16) * Fix return types and color band range (#5) * fix types and remove non-existing function * fix tests * fix color band range * increment patch number * fix range and tests * changelog * bring back function * more tests * missing include * typo * better docstrings * correctness * ws * docs only publish * more examples * point to pypi (#3) * remove multi-statement lines * remove vb references * better explanation * simpler * cleanup and make sure the scripts work * Update readme.rst * remove unnecessary fallback * Add convenience message boxes and input dialogs via Win32 (#15) * progress * order * allow ok for localisation * add integration test * format * lint * fix test * working * lint * i18n * format * lint * fnish lint * review * placement * omit test coverage for win32 ui heavy class * add to changelog * fix coverage * more friendly win32 error handling * Update src/moldflow/message_box.py Co-authored-by: Sankalp Shrivastava --------- Co-authored-by: Sankalp Shrivastava * Improve MessageBox documentation (#21) * progress * order * allow ok for localisation * add integration test * format * lint * fix test * working * lint * i18n * format * lint * fnish lint * review * placement * omit test coverage for win32 ui heavy class * add to changelog * fix coverage * more friendly win32 error handling * Update src/moldflow/message_box.py Co-authored-by: Sankalp Shrivastava * better doc --------- Co-authored-by: Sankalp Shrivastava * bump version * update changelog --------- Co-authored-by: Sankalp Shrivastava * Fix readme links on pypi release 26 (#28) * Fix links in README (#27) * fix links * changelog * new pypi deployment * put version backl --------- Co-authored-by: Sankalp Shrivastava --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e26bd67..b75043f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added convenience class for showing message boxes and text input dialogs via Win32 +- N/A + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed +- N/A + +### Security +- N/A + +## [26.0.3] + +### Added +- N/A + +### Changed +- N/A + +### Deprecated +- N/A + +### Removed +- N/A + +### Fixed - Fixed README links +### Security +- N/A + +## [26.0.2] - 2025-10-10 + +### Added +- Added convenience class for showing message boxes and text input dialogs via Win32 +- Add more examples in the documentation + ### Changed - N/A @@ -53,6 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial version aligned with Moldflow Synergy 2026.0.1 - Python 3.10-3.13 compatibility -[Unreleased]: https://github.com/Autodesk/moldflow-api/compare/v26.0.1...HEAD +[Unreleased]: https://github.com/Autodesk/moldflow-api/compare/v26.0.3...HEAD +[26.0.3]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.3 +[26.0.2]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.2 [26.0.1]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.1 [26.0.0]: https://github.com/Autodesk/moldflow-api/releases/tag/v26.0.0 From eb3e9cd7bf7c4a7f29ed03ebb22c573e67a7d991 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:08:48 +0530 Subject: [PATCH 11/14] Expose GeomType enum in package __init__.py (#40) * Initial plan * Add GeomType to package exports in __init__.py - Added GeomType import to src/moldflow/__init__.py in alphabetical order - Updated test_unit_mesh_generator.py to import GeomType from moldflow instead of moldflow.common - Users can now use: from moldflow import GeomType Co-authored-by: sankalps0549 <230025240+sankalps0549@users.noreply.github.com> * Update CHANGELOG.md with GeomType fix Added entry under Unreleased > Fixed section documenting the GeomType enum exposure fix Co-authored-by: osinjoku <49887472+osinjoku@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sankalps0549 <230025240+sankalps0549@users.noreply.github.com> Co-authored-by: osinjoku <49887472+osinjoku@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/moldflow/__init__.py | 1 + tests/api/unit_tests/test_unit_mesh_generator.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b75043f..d094fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - N/A ### Fixed -- N/A +- Fixed `GeomType` enum not being exposed in package `__init__.py` - users can now import it directly with `from moldflow import GeomType` ### Security - N/A diff --git a/src/moldflow/__init__.py b/src/moldflow/__init__.py index 0a8f1cc..d43f07a 100644 --- a/src/moldflow/__init__.py +++ b/src/moldflow/__init__.py @@ -69,6 +69,7 @@ from .common import EdgeDisplayOptions from .common import EntityType from .common import GradingFactor +from .common import GeomType from .common import ImportUnitIndex from .common import ImportUnits from .common import ItemType diff --git a/tests/api/unit_tests/test_unit_mesh_generator.py b/tests/api/unit_tests/test_unit_mesh_generator.py index fdc2697..3dda457 100644 --- a/tests/api/unit_tests/test_unit_mesh_generator.py +++ b/tests/api/unit_tests/test_unit_mesh_generator.py @@ -6,8 +6,8 @@ """ import pytest -from moldflow import MeshGenerator -from moldflow.common import ( +from moldflow import ( + MeshGenerator, GeomType, NurbsAlgorithm, CoolType, From e688e39be97e603fe2791ad8e63cc7994f5ce612 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:33:17 +0530 Subject: [PATCH 12/14] Fix GeomType enum value and add missing return types to MeshGenerator (#43) * Initial plan * Fix GeomType enum and add return types to MeshGenerator methods Co-authored-by: sankalps0549 <230025240+sankalps0549@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sankalps0549 <230025240+sankalps0549@users.noreply.github.com> --- src/moldflow/common.py | 2 +- src/moldflow/mesh_generator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/moldflow/common.py b/src/moldflow/common.py index 60569c1..d55b5cd 100644 --- a/src/moldflow/common.py +++ b/src/moldflow/common.py @@ -408,7 +408,7 @@ class GeomType(Enum): """ AUTO_DETECT = "Auto-Detect" - DUAL_DOMAIN = "Dual Domain" + FUSION = "Fusion" MIDPLANE = "Midplane" diff --git a/src/moldflow/mesh_generator.py b/src/moldflow/mesh_generator.py index 8de811c..25e6813 100644 --- a/src/moldflow/mesh_generator.py +++ b/src/moldflow/mesh_generator.py @@ -37,16 +37,22 @@ def __init__(self, _mesh_generator): process_log(__name__, LogMessage.CLASS_INIT, locals(), name="MeshGenerator") self.mesh_generator = safe_com(_mesh_generator) - def generate(self): + def generate(self) -> bool: """ Generate the mesh using the MeshGenerator instance. + + Returns: + bool: True if mesh generation was successful, False otherwise. """ process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="generate") return self.mesh_generator.Generate - def save_options(self): + def save_options(self) -> bool: """ Save the mesh generation options. + + Returns: + bool: True if options were saved successfully, False otherwise. """ process_log(__name__, LogMessage.FUNCTION_CALL, locals(), name="save_options") return self.mesh_generator.SaveOptions From 97a7bbd73e8d4a2fd5ec595db434166bc18725a6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:43:04 +1100 Subject: [PATCH 13/14] Add Python 3.14 support (#37) * Initial plan * Add Python 3.14 support to CI, metadata, and documentation Co-authored-by: osinjoku <49887472+osinjoku@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: osinjoku <49887472+osinjoku@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- README.md | 2 +- setup.cfg.in | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f7132d..a0a7769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [windows-2022] - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v5 @@ -55,7 +55,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install dependencies run: | diff --git a/README.md b/README.md index 349d83d..3275017 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Moldflow API is a Python wrapper library for the Synergy API, designed to simpli Before you begin, ensure you have: - Windows 10/11 -- Python 3.10.x - 3.13.x +- Python 3.10.x - 3.14.x - Autodesk Moldflow Synergy 2026.0.1 or later ## Install diff --git a/setup.cfg.in b/setup.cfg.in index b3f1adc..774836a 100644 --- a/setup.cfg.in +++ b/setup.cfg.in @@ -23,6 +23,7 @@ classifiers = Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Topic :: Scientific/Engineering Topic :: Software Development :: Libraries :: Python Modules keywords = moldflow, autodesk, cae, simulation, manufacturing, injection molding, plastic injection, analysis, automation, api, synergy @@ -31,7 +32,7 @@ keywords = moldflow, autodesk, cae, simulation, manufacturing, injection molding package_dir = = src packages = find: -python_requires = >=3.10, <3.14 +python_requires = >=3.10, <3.15 include_package_data = True install_requires = pywin32==311; platform_system=='Windows' From 68029a9be993fae56478e78750bb05f675bec1d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 06:56:26 +0000 Subject: [PATCH 14/14] Initial plan