From b7b16839eff775127393166c36605b553043e05e Mon Sep 17 00:00:00 2001 From: BHoMBot Date: Mon, 6 Jul 2026 11:31:08 +0100 Subject: [PATCH 01/50] Update assembly file version to 9.3.0.0 --- Python_Engine/Python_Engine.csproj | 2 +- Python_oM/Python_oM.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Python_Engine.csproj b/Python_Engine/Python_Engine.csproj index c2e02e29..429318f6 100644 --- a/Python_Engine/Python_Engine.csproj +++ b/Python_Engine/Python_Engine.csproj @@ -7,7 +7,7 @@ BHoM Copyright © https://github.com/BHoM BH.Engine.Python - 9.2.0.0 + 9.3.0.0 ..\Build\ diff --git a/Python_oM/Python_oM.csproj b/Python_oM/Python_oM.csproj index 251d3dbe..854b7fe8 100644 --- a/Python_oM/Python_oM.csproj +++ b/Python_oM/Python_oM.csproj @@ -8,7 +8,7 @@ BHoM Copyright © https://github.com/BHoM BH.oM.Python - 9.2.0.0 + 9.3.0.0 ..\Build\ From 0ea01ae86a9faba01fbf65935898ff6e8097e606 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 16 Jul 2026 10:59:23 +0100 Subject: [PATCH 02/50] build dir if not exists + migration recovery for the long lost logs --- .../Python/src/python_toolkit/bhom/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index bc0efd26..79b8bb54 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -18,5 +18,16 @@ DISABLE_ANALYTICS = True if not BHOM_LOG_FOLDER.exists(): - BHOM_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" - BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) \ No newline at end of file + + try: + BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) + except Exception as e: + BHOM_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" + BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) + + +#migration recovery for any logs in the temp folder +old_temp_log = Path(tempfile.gettempdir()) / "BHoM" / "Logs" +if old_temp_log.exists(): + for file in old_temp_log.glob("*.log"): + file.rename(BHOM_LOG_FOLDER / file.name) \ No newline at end of file From 7eabb79acfd6ae2321fbf3d3496e82c2c4f6719c Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 16 Jul 2026 11:03:05 +0100 Subject: [PATCH 03/50] shuffled order of events; ensure we only migrate if the main logs folder exists now --- .../Python/src/python_toolkit/bhom/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index 79b8bb54..4933adf6 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -7,6 +7,7 @@ import importlib.metadata BHOM_LOG_FOLDER = Path(path.expandvars("%PROGRAMDATA%/BHoM/Logs")) +TEMP_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" TOOLKIT_NAME = "Python_Toolkit" BHOM_VERSION = importlib.metadata.version("python_toolkit") @@ -21,13 +22,14 @@ try: BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) + + #migration recovery for any logs in the temp folder + if TEMP_LOG_FOLDER.exists(): + for file in TEMP_LOG_FOLDER.glob("*.log"): + file.rename(BHOM_LOG_FOLDER / file.name) + except Exception as e: - BHOM_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" + BHOM_LOG_FOLDER = TEMP_LOG_FOLDER BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) -#migration recovery for any logs in the temp folder -old_temp_log = Path(tempfile.gettempdir()) / "BHoM" / "Logs" -if old_temp_log.exists(): - for file in old_temp_log.glob("*.log"): - file.rename(BHOM_LOG_FOLDER / file.name) \ No newline at end of file From 2a20b4ca9e759f00464af86e0746eb282fcaacd9 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Mon, 20 Jul 2026 11:13:43 +0100 Subject: [PATCH 04/50] fix typos and add __Time__ field --- Python_Engine/Python/src/python_toolkit/bhom/analytics.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index 4b684a3e..d5d9e91f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -89,15 +89,16 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: "CallerName": first_entry.CallerName, "SelectedItem": first_entry.SelectedItem, "Computer": socket.gethostname(), - "UserName": os.environ.get("USERNAME"), + "Username": os.environ.get("USERNAME"), "BHoMVersion": BHOM_VERSION, "FileId": file_id, "FileName": filename, "ProjectID": project_id, "NbCallingComponents": len(set([a.ComponentId for a in methodgroup])), - "TotalNbCals": len(methodgroup), + "TotalNbCalls": len(methodgroup), "Errors": list(itertools.chain.from_iterable([x.Errors for x in methodgroup])), - "_t": "BH.oM.BHoMAnalytics.UsageEntry" + "_t": "BH.oM.BHoMAnalytics.UsageEntry", + "__Time__": datetime.now() }) return db_entries From 9c0a8dac89a24c8555d921b3a0910a0d9eb60151 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Mon, 20 Jul 2026 11:46:35 +0100 Subject: [PATCH 05/50] use shorthand BHoM version and add _bhomVersion field to be more consistent with other UIs --- Python_Engine/Python/src/python_toolkit/bhom/analytics.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index d5d9e91f..2b1111a2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -71,6 +71,8 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: usage_log_entries.sort(key=lambda x: x.ProjectID) + short_bhom_version = BHOM_VERSION.rsplit(".", 2)[0] + for file_id, filegroup in groupby(usage_log_entries, lambda x: x.FileId): filegroup = list(filegroup) project_id = filegroup[0].ProjectID @@ -90,7 +92,7 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: "SelectedItem": first_entry.SelectedItem, "Computer": socket.gethostname(), "Username": os.environ.get("USERNAME"), - "BHoMVersion": BHOM_VERSION, + "BHoMVersion": short_bhom_version, "FileId": file_id, "FileName": filename, "ProjectID": project_id, @@ -98,7 +100,8 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: "TotalNbCalls": len(methodgroup), "Errors": list(itertools.chain.from_iterable([x.Errors for x in methodgroup])), "_t": "BH.oM.BHoMAnalytics.UsageEntry", - "__Time__": datetime.now() + "__Time__": datetime.now(), + "_bhomVersion": short_bhom_version }) return db_entries From 236eafca31b908e49399a70d8cb9ef8bf2227275 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Tue, 21 Jul 2026 11:28:52 +0100 Subject: [PATCH 06/50] use left split instead of right split to be more robust --- Python_Engine/Python/src/python_toolkit/bhom/analytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index 2b1111a2..ee470fed 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -71,7 +71,7 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: usage_log_entries.sort(key=lambda x: x.ProjectID) - short_bhom_version = BHOM_VERSION.rsplit(".", 2)[0] + short_bhom_version = ".".join(BHOM_VERSION.split(".", 2)[0:1]) for file_id, filegroup in groupby(usage_log_entries, lambda x: x.FileId): filegroup = list(filegroup) From b3cb7cccee49d69af2b9546131223f581e9c0993 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Tue, 21 Jul 2026 11:31:54 +0100 Subject: [PATCH 07/50] Apply suggestion from @Tom-Kingstone --- Python_Engine/Python/src/python_toolkit/bhom/analytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index ee470fed..a39b66c3 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -71,7 +71,7 @@ def summarise_usage_logs(usage_log_entries:List[UsageLogEntry]) -> List[Dict]: usage_log_entries.sort(key=lambda x: x.ProjectID) - short_bhom_version = ".".join(BHOM_VERSION.split(".", 2)[0:1]) + short_bhom_version = ".".join(BHOM_VERSION.split(".", 2)[0:2]) for file_id, filegroup in groupby(usage_log_entries, lambda x: x.FileId): filegroup = list(filegroup) From 4d8a777ca16c677d51826462237c019359a509c4 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 23 Jul 2026 11:45:56 +0100 Subject: [PATCH 08/50] add some unit enums with converters --- .../src/python_toolkit/units/__init__.py | 1 + .../Python/src/python_toolkit/units/energy.py | 18 ++++++++++++++++++ .../Python/src/python_toolkit/units/length.py | 17 +++++++++++++++++ .../Python/src/python_toolkit/units/speed.py | 13 +++++++++++++ .../src/python_toolkit/units/temperature.py | 13 +++++++++++++ 5 files changed, 62 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/units/__init__.py create mode 100644 Python_Engine/Python/src/python_toolkit/units/energy.py create mode 100644 Python_Engine/Python/src/python_toolkit/units/length.py create mode 100644 Python_Engine/Python/src/python_toolkit/units/speed.py create mode 100644 Python_Engine/Python/src/python_toolkit/units/temperature.py diff --git a/Python_Engine/Python/src/python_toolkit/units/__init__.py b/Python_Engine/Python/src/python_toolkit/units/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/units/energy.py b/Python_Engine/Python/src/python_toolkit/units/energy.py new file mode 100644 index 00000000..f1fad47d --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/energy.py @@ -0,0 +1,18 @@ +from enum import Enum + +class EnergyUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to joules, lambda from joules) + """ + BTU = ("British Thermal Unit", lambda btu: btu * 1.05505585262e3, lambda j: j / 1.05505585262e3) + J = ("Joule", lambda j: j, lambda j: j) + kBTU = ("Kilo British Thermal Unit", lambda kbtu: kbtu * 1.05505585262e6, lambda j: j / 1.05505585262e6) + kJ = ("Kilojoule", lambda kj: kj * 1e3, lambda j: j / 1e3) + kWh = ("kilowatt Hour", lambda kwh: kwh * 3.6e6, lambda j: j / 3.6e6) + MBTU = ("Mega British Thermal Unit", lambda mbtu: mbtu * 1.05505585262e9, lambda j: j / 1.05505585262e9) + MJ = ("Megajoule", lambda mj: mj * 1e6, lambda j: j / 1e6) + MWh = ("Megawatt Hour", lambda mwh: mwh * 3.6e9, lambda j: j / 3.6e9) + Wh = ("Watt Hour", lambda wh: wh * 3.6e3, lambda j: j / 3.6e3) + + def convert(self, value: float, to_unit: "EnergyUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/length.py b/Python_Engine/Python/src/python_toolkit/units/length.py new file mode 100644 index 00000000..f8ef8688 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/length.py @@ -0,0 +1,17 @@ +from enum import Enum + +class LengthUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to metres, lambda from metres) + """ + cm = ("Centimetre", lambda cm: cm * 1e-2, lambda m: m / 1e-2) + ft = ("Foot", lambda ft: ft * 3.048e-1, lambda m: m / 3.048e-1) + In = ("Inch", lambda In: In * 2.54e-2, lambda m: m / 2.54e-2) + km = ("Kilometre", lambda km: km * 1e3, lambda m: m / 1e3) + m = ("Metre", lambda m: m, lambda m: m) + mi = ("Mile", lambda mi: mi * 1.609344e3, lambda m: m / 1.609344e3) + mm = ("Millimetre", lambda mm: mm * 1e-3, lambda m: m / 1e-3) + yd = ("Yard", lambda yd: yd * 9.144e-1, lambda m: m / 9.144e-1) + + def convert(self, value: float, to_unit: "LengthUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/speed.py b/Python_Engine/Python/src/python_toolkit/units/speed.py new file mode 100644 index 00000000..26384894 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/speed.py @@ -0,0 +1,13 @@ +from enum import Enum + +class SpeedUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to metres per secomd, lambda from metres per second) + """ + m_s = ("Metres Per Second", lambda m_s: m_s, lambda m_s: m_s) + km_h = ("Kilometres Per Hour", lambda km_h: km_h * 3.6e6, lambda m_s: m_s / 3.6e6) + mi_h = ("Miles Per Hour", lambda mi_h: mi_h * 5.7936384e6, lambda m_s: m_s / 5.7936384e6) + ft_s = ("Feet Per Second", lambda ft_s: ft_s * 3.048e-1, lambda m_s: m_s / 3.048e-1) + + def convert(self, value: float, to_unit: "SpeedUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/temperature.py b/Python_Engine/Python/src/python_toolkit/units/temperature.py new file mode 100644 index 00000000..efca3378 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/temperature.py @@ -0,0 +1,13 @@ +from enum import Enum + +class TemperatureUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to kelvin, lambda from kelvin) + """ + C = ("Celcius", lambda c: c + 273.15, lambda k: k - 273.15) + K = ("Kelvin", lambda k: k, lambda k: k) + F = ("Fahrenheit", lambda f: (f + 459.67) * (5/9), lambda k: (k / (5/9)) - 459.67) + R = ("Rankine", lambda r: r * (5/9), lambda k: k / (5/9)) + + def convert(self, value: float, to_unit: "TemperatureUnit"): + return to_unit.value[2]((self.value[1](value))) From 0425544a4146c76505097db70b63ea9908c564b7 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Fri, 24 Jul 2026 11:50:29 +0100 Subject: [PATCH 09/50] fix conversions for speed --- .../Python/src/python_toolkit/units/speed.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/units/speed.py b/Python_Engine/Python/src/python_toolkit/units/speed.py index 26384894..8ccb6f79 100644 --- a/Python_Engine/Python/src/python_toolkit/units/speed.py +++ b/Python_Engine/Python/src/python_toolkit/units/speed.py @@ -3,10 +3,18 @@ class SpeedUnit(Enum): """Each unit value is a tuple in the format: (display name, lambda to metres per secomd, lambda from metres per second) + + Usage: + value_in_km_h = 1 + value_in_m_s = SpeedUnit.km_h.convert(value_in_km_h, SpeedUnit.m_s) + print(value_in_m_s) + + >>> + """ m_s = ("Metres Per Second", lambda m_s: m_s, lambda m_s: m_s) - km_h = ("Kilometres Per Hour", lambda km_h: km_h * 3.6e6, lambda m_s: m_s / 3.6e6) - mi_h = ("Miles Per Hour", lambda mi_h: mi_h * 5.7936384e6, lambda m_s: m_s / 5.7936384e6) + km_h = ("Kilometres Per Hour", lambda km_h: (km_h * 1e3) / 3.6e3, lambda m_s: (m_s / 1e3) * 3.6e3) + mi_h = ("Miles Per Hour", lambda mi_h: (mi_h * 1.609344e3) / 3.6e3, lambda m_s: (m_s / 1.609344e3) * 3.6e3) ft_s = ("Feet Per Second", lambda ft_s: ft_s * 3.048e-1, lambda m_s: m_s / 3.048e-1) def convert(self, value: float, to_unit: "SpeedUnit"): From 7204ab39752b48cc32e7f6505ce011573d8e8e0d Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Fri, 24 Jul 2026 11:51:01 +0100 Subject: [PATCH 10/50] remove partial example in favour of doing it in future --- Python_Engine/Python/src/python_toolkit/units/speed.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/units/speed.py b/Python_Engine/Python/src/python_toolkit/units/speed.py index 8ccb6f79..31e55567 100644 --- a/Python_Engine/Python/src/python_toolkit/units/speed.py +++ b/Python_Engine/Python/src/python_toolkit/units/speed.py @@ -3,14 +3,6 @@ class SpeedUnit(Enum): """Each unit value is a tuple in the format: (display name, lambda to metres per secomd, lambda from metres per second) - - Usage: - value_in_km_h = 1 - value_in_m_s = SpeedUnit.km_h.convert(value_in_km_h, SpeedUnit.m_s) - print(value_in_m_s) - - >>> - """ m_s = ("Metres Per Second", lambda m_s: m_s, lambda m_s: m_s) km_h = ("Kilometres Per Hour", lambda km_h: (km_h * 1e3) / 3.6e3, lambda m_s: (m_s / 1e3) * 3.6e3) From 8a66544f815851bb75121fd6f563b5860b52bcaa Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Fri, 24 Jul 2026 15:11:51 +0100 Subject: [PATCH 11/50] add area and power units --- .../Python/src/python_toolkit/units/__init__.py | 16 +++++++++++++++- .../Python/src/python_toolkit/units/area.py | 13 +++++++++++++ .../Python/src/python_toolkit/units/power.py | 15 +++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 Python_Engine/Python/src/python_toolkit/units/area.py create mode 100644 Python_Engine/Python/src/python_toolkit/units/power.py diff --git a/Python_Engine/Python/src/python_toolkit/units/__init__.py b/Python_Engine/Python/src/python_toolkit/units/__init__.py index 5f282702..87a074dd 100644 --- a/Python_Engine/Python/src/python_toolkit/units/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/units/__init__.py @@ -1 +1,15 @@ - \ No newline at end of file +from .area import AreaUnit +from .energy import EnergyUnit +from .length import LengthUnit +from .power import PowerUnit +from .speed import SpeedUnit +from .temperature import TemperatureUnit + +__all__ = [ + "AreaUnit", + "EnergyUnit", + "LengthUnit", + "PowerUnit", + "SpeedUnit", + "TemperatureUnit" +] \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/units/area.py b/Python_Engine/Python/src/python_toolkit/units/area.py new file mode 100644 index 00000000..e59b6e98 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/area.py @@ -0,0 +1,13 @@ +from enum import Enum + +class AreaUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to metres squared, lambda from metres squared) + """ + ft2 = ("Feet Squared", lambda ft2: ft2 * (3.048e-1**2), lambda m2: m2 / (3.048e-1**2)) + km2 = ("Kilometres Squared", lambda km2: km2 * (1e3**2), lambda m2: m2 / (1e3**2)) + m2 = ("Metres Squared", lambda m2: m2, lambda m2: m2) + mi2 = ("Miles Squared", lambda mi2: mi2 * (1.609344e3**2), lambda m2: m2 / (1.609344e3**2)) + + def convert(self, value: float, to_unit: "AreaUnit"): + return to_unit.value[2](self.value[1](value)) diff --git a/Python_Engine/Python/src/python_toolkit/units/power.py b/Python_Engine/Python/src/python_toolkit/units/power.py new file mode 100644 index 00000000..386a0608 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/units/power.py @@ -0,0 +1,15 @@ +from enum import Enum + +class PowerUnit(Enum): + """Each unit value is a tuple in the format: + (display name, lambda to watts, lambda from watts) + """ + BTU_h = ("British Thermal Unit Per Hour", lambda btu_h: btu_h * (1.05505585262e3 / 3600), lambda w: w / (1.05505585262e3 / 3600)) + W = ("Watt", lambda w: w, lambda w: w) + kBTU_h = ("Kilo British Thermal Unit Per Hour", lambda kbtu_h: kbtu_h * 1.05505585262e6, lambda w: w / 1.05505585262e6) + kW = ("Kilowatt", lambda kw: kw * 1e3, lambda w: w / 1e3) + MBTU_h = ("Mega British Thermal Unit Per Hour", lambda mbtu_h: mbtu_h * (1.05505585262e9 / 3600), lambda w: w / (1.05505585262e9 / 3600)) + MW = ("Megawatt", lambda mj: mj * 1e6, lambda j: j / 1e6) + + def convert(self, value: float, to_unit: "PowerUnit"): + return to_unit.value[2](self.value[1](value)) From 448e2087cf5dfd49ddb2d41aa1b76a3fe52e42bf Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Fri, 24 Jul 2026 15:13:14 +0100 Subject: [PATCH 12/50] minor order of operations wrangling --- Python_Engine/Python/src/python_toolkit/units/speed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/units/speed.py b/Python_Engine/Python/src/python_toolkit/units/speed.py index 31e55567..f1ca7bdb 100644 --- a/Python_Engine/Python/src/python_toolkit/units/speed.py +++ b/Python_Engine/Python/src/python_toolkit/units/speed.py @@ -5,8 +5,8 @@ class SpeedUnit(Enum): (display name, lambda to metres per secomd, lambda from metres per second) """ m_s = ("Metres Per Second", lambda m_s: m_s, lambda m_s: m_s) - km_h = ("Kilometres Per Hour", lambda km_h: (km_h * 1e3) / 3.6e3, lambda m_s: (m_s / 1e3) * 3.6e3) - mi_h = ("Miles Per Hour", lambda mi_h: (mi_h * 1.609344e3) / 3.6e3, lambda m_s: (m_s / 1.609344e3) * 3.6e3) + km_h = ("Kilometres Per Hour", lambda km_h: km_h * (1e3 / 3.6e3), lambda m_s: m_s / (1e3 / 3.6e3)) + mi_h = ("Miles Per Hour", lambda mi_h: mi_h * (1.609344e3 / 3.6e3), lambda m_s: m_s / (1.609344e3 / 3.6e3)) ft_s = ("Feet Per Second", lambda ft_s: ft_s * 3.048e-1, lambda m_s: m_s / 3.048e-1) def convert(self, value: float, to_unit: "SpeedUnit"): From c1d20d390cd6c23244abd41a044f69ff60b0d7a7 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 28 Jul 2026 11:10:16 +0100 Subject: [PATCH 13/50] make median and mean lines optional --- .../Python/src/python_toolkit/plot/diurnal.py | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py index 9eb2c6fe..a7d53a1e 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py +++ b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py @@ -20,6 +20,8 @@ def diurnal( series: pd.Series, ax: plt.Axes = None, period: str = "daily", + median: bool = True, + mean: bool = True, **kwargs, ) -> plt.Axes: """Plot a profile aggregated across days in the specified timeframe. @@ -31,6 +33,10 @@ def diurnal( A matplotlib Axes object. Defaults to None. period (str, optional): The period to aggregate over. Must be one of "dailyy", "weekly", or "monthly". Defaults to "daily". + median (bool, optional): + Whether to plot the median line. Default `True`. + mean (bool, optional): + Whether to plot the mean line. Default `True`. **kwargs (Dict[str, Any], optional): Additional keyword arguments to pass to the matplotlib plotting function. legend (bool, optional): @@ -43,7 +49,6 @@ def diurnal( A matplotlib Axes object. """ - if not isinstance(series.index, pd.DatetimeIndex): raise ValueError("Series passed is not datetime indexed.") @@ -183,24 +188,26 @@ def diurnal( label="_nolegend_", ) # mean/median - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] - + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], - c=color, - ls="-", - lw=1, - label="Average" if n == 0 else "_nolegend_", - ) - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] - + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], - c=color, - ls="--", - lw=1, - label="Median" if n == 0 else "_nolegend_", - ) + if mean: + ax.plot( + range(len(df) + 1)[i : i + 25], + (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] + + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], + c=color, + ls="-", + lw=1, + label="Average" if n == 0 else "_nolegend_", + ) + if median: + ax.plot( + range(len(df) + 1)[i : i + 25], + (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] + + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], + c=color, + ls="--", + lw=1, + label="Median" if n == 0 else "_nolegend_", + ) # format axes ax.set_xlim(0, len(df)) From 4bf1ff2fb6ad16dcd9793e606d89d089911da4f7 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 29 Jul 2026 14:49:14 +0100 Subject: [PATCH 14/50] make quantile_range an explicit key word argument and document it --- Python_Engine/Python/src/python_toolkit/plot/diurnal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py index a7d53a1e..060f1de8 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py +++ b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py @@ -2,6 +2,7 @@ import calendar import textwrap +from typing import Tuple import matplotlib.collections as mcollections import matplotlib.lines as mlines @@ -20,6 +21,7 @@ def diurnal( series: pd.Series, ax: plt.Axes = None, period: str = "daily", + quantile_range: Tuple[float, float] = (0.05, 0.95), median: bool = True, mean: bool = True, **kwargs, @@ -33,6 +35,8 @@ def diurnal( A matplotlib Axes object. Defaults to None. period (str, optional): The period to aggregate over. Must be one of "dailyy", "weekly", or "monthly". Defaults to "daily". + quantile_range (Tuple[float, float]): + The quantile range to display in a lighter (30% alpha) colour on the plot. Defaults to (0.05, 0.95). median (bool, optional): Whether to plot the median line. Default `True`. mean (bool, optional): @@ -66,7 +70,6 @@ def diurnal( raise ValueError("minmax_range must be increasing.") minmax_alpha = kwargs.pop("minmax_alpha", 0.1) - quantile_range = kwargs.pop("quantile_range", [0.05, 0.95]) if quantile_range[0] > quantile_range[1]: raise ValueError("quantile_range must be increasing.") if quantile_range[0] < minmax_range[0] or quantile_range[1] > minmax_range[1]: From 62883875c105bf8948cfc5648f6695de8e999023 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Mon, 10 Aug 2026 11:40:45 +0100 Subject: [PATCH 15/50] fix redefining variables --- Python_Engine/Python/src/python_toolkit/plot/diurnal.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py index 060f1de8..ed42fdaf 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py +++ b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py @@ -134,14 +134,14 @@ def diurnal( # Get values to plot minima = group.min() lower = group.quantile(quantile_range[0]) - median = group.median() - mean = group.mean() + median_series = group.median() + mean_series = group.mean() upper = group.quantile(quantile_range[1]) maxima = group.max() # create df for re-indexing df = pd.concat( - [minima, lower, median, mean, upper, maxima], + [minima, lower, median_series, mean_series, upper, maxima], axis=1, keys=["minima", "lower", "median", "mean", "upper", "maxima"], ).reindex(target_idx) From a7cc69e7a28182653049c31848207916d806cbeb Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 12 Aug 2026 16:14:14 +0100 Subject: [PATCH 16/50] upgraded calendar widget for rigid sizing --- .../bhom_tkinter/widgets/widget_calendar.py | 86 +++++++++++++++---- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py index ad94474c..2e899779 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -27,12 +27,16 @@ def __init__( day_button_padx: int = 1, day_button_pady: int = 1, day_button_text_alignment: Literal["left", "center", "right"] = "center", + fixed_week_rows: int | None = None, + selector_position: Literal["top", "bottom"] = "bottom", + selection_label_format: Literal["short", "long"] = "short", **kwargs): super().__init__(parent, **kwargs) self.year = def_year self.month = def_month + self.day = def_day self.show_year_selector = show_year_selector self.year_min = year_min self.year_max = year_max @@ -44,6 +48,17 @@ def __init__( alignment_candidate = "center" self.day_button_text_alignment = alignment_candidate self.day_button_style = f"CalendarDay.{id(self)}.TButton" + self.fixed_week_rows = ( + max(1, int(fixed_week_rows)) if fixed_week_rows is not None else None + ) + selector_candidate = str(selector_position).strip().lower() + self.selector_position = ( + selector_candidate if selector_candidate in {"top", "bottom"} else "bottom" + ) + label_format = str(selection_label_format).strip().lower() + self.selection_label_format = ( + label_format if label_format in {"short", "long"} else "short" + ) anchor_map = { "left": "w", @@ -53,20 +68,17 @@ def __init__( ttk.Style(self).configure(self.day_button_style, anchor=anchor_map[self.day_button_text_alignment]) self.cal_frame = ttk.Frame(self.content_frame) - self.cal_frame.pack(side="top", fill="x") - self.month_frame = ttk.Frame(self.content_frame) - self.month_frame.pack(side="top", anchor=self._pack_anchor) - self.date_frame = ttk.Frame(self.content_frame) - self.date_frame.pack(side="top", fill="x") if self.show_year_selector: self.year_selector() self.month_selector() + self._pack_sections() self._initialized = False - self.set_day(def_day) + self._clamp_day() self.redraw() + self._refresh_selection_label() self._initialized = True def year_selector(self): @@ -93,6 +105,44 @@ def month_selector(self): ) self.month_dropdown.pack(side="left", padx=4, pady=4) + def _pack_sections(self) -> None: + """Pack month selectors and calendar sections in the configured order.""" + for frame in (self.cal_frame, self.month_frame, self.date_frame): + frame.pack_forget() + + if self.selector_position == "top": + section_order = (self.month_frame, self.cal_frame, self.date_frame) + else: + section_order = (self.cal_frame, self.month_frame, self.date_frame) + + for frame in section_order: + if frame is self.month_frame: + frame.pack(side="top", anchor=self._pack_anchor, fill="x") + else: + frame.pack(side="top", fill="x") + + def _clamp_day(self) -> None: + last_day = calendar.monthrange(self.year, self.month)[1] + if self.day > last_day: + self.day = last_day + + def _refresh_selection_label(self) -> None: + for child in self.date_frame.winfo_children(): + child.destroy() + + try: + selected = datetime.date(self.year, self.month, self.day) + if self.selection_label_format == "long": + text = f"Selected: {selected.strftime('%A %d %B %Y')}" + else: + text = f"Selected Date: {self.months[self.month - 1]} {self.day}" + except ValueError as error: + text = f"Selected: invalid date ({error})" + + label = Label(self.date_frame, text=text) + self.align_child_text(label) + label.pack(anchor=self._pack_anchor, padx=4, pady=4) + def set_year(self, value): """Update the selected year and redraw the calendar. @@ -100,7 +150,9 @@ def set_year(self, value): value: The selected year as a string. """ self.year = int(value) + self._clamp_day() self.redraw() + self._refresh_selection_label() def set_month(self, value): """Update the selected month and redraw the calendar. @@ -109,7 +161,9 @@ def set_month(self, value): value: The selected month name as a string. """ self.month = self.months.index(value) + 1 + self._clamp_day() self.redraw() + self._refresh_selection_label() def redraw(self): """Rebuild the month grid buttons for the current month and year.""" @@ -124,9 +178,13 @@ def redraw(self): self.align_child_text(label) label.grid(row=0, column=col, sticky="nsew") - cal = calendar.monthcalendar(self.year, self.month) + weeks = calendar.monthcalendar(self.year, self.month) + if self.fixed_week_rows is not None: + while len(weeks) < self.fixed_week_rows: + weeks.append([0, 0, 0, 0, 0, 0, 0]) + weeks = weeks[: self.fixed_week_rows] - for row, week in enumerate(cal): + for row, week in enumerate(weeks): for col, day in enumerate(week): text = "" if day == 0 else day state = "normal" if day > 0 else "disabled" @@ -154,14 +212,7 @@ def set_day(self, num): if not num or num <= 0: return self.day = num - - for child in self.date_frame.winfo_children(): - child.destroy() - - date = self.months[self.month-1] + " " + str(self.day) - label = Label(self.date_frame, text=f"Selected Date: {date}") - self.align_child_text(label) - label.pack(anchor=self._pack_anchor, padx=4, pady=4) + self._refresh_selection_label() if self._initialized: self._fire_on_change(self.get()) @@ -195,8 +246,9 @@ def set(self, value: datetime.date): self.year_dropdown.set(str(self.year)) if hasattr(self, 'month_dropdown'): self.month_dropdown.set(self.months[self.month - 1]) - self.set_day(self.day) + self._clamp_day() self.redraw() + self._refresh_selection_label() def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Validate the currently selected date. From ebc9f19f8a6f9c2b3182cd1a9db8e837fad6aa87 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 12 Aug 2026 16:49:40 +0100 Subject: [PATCH 17/50] Added bhom base child window, and refactored dependant windows refactored some of bhom base window logic to do this, into bhom window shell added a modal window, that is a simple use case of this for child windows, ensure rendering of child windows --- .../python_toolkit/bhom_tkinter/__init__.py | 4 + .../bhom_tkinter/bhom_base_child_window.py | 148 +++++ .../bhom_tkinter/bhom_base_window.py | 595 +---------------- .../bhom_tkinter/bhom_window_shell.py | 612 ++++++++++++++++++ .../bhom_tkinter/windows/__init__.py | 4 + .../windows/directory_file_selector.py | 4 +- .../bhom_tkinter/windows/modal_window.py | 50 ++ .../bhom_tkinter/windows/processing_window.py | 53 +- .../bhom_tkinter/windows/warning_box.py | 4 +- .../Python/tests/test_bhom_tkinter_ui.py | 32 + 10 files changed, 895 insertions(+), 611 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py index db2b5827..0dbeb309 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py @@ -13,7 +13,9 @@ RadioSelection, ValidatedEntryBox, ) +from .bhom_base_child_window import BHoMBaseChildWindow from .windows import ( + BHoMModalWindow, DirectoryFileSelector, LandingPage, ProcessingWindow, @@ -41,6 +43,8 @@ "PathSelector", "RadioSelection", "ValidatedEntryBox", + "BHoMBaseChildWindow", + "BHoMModalWindow", "DirectoryFileSelector", "LandingPage", "ProcessingWindow", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py new file mode 100644 index 00000000..3946fd88 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_child_window.py @@ -0,0 +1,148 @@ +"""Themed Toplevel base class for BHoM child windows and dialogs.""" + +from __future__ import annotations + +import tkinter as tk +from typing import Callable, Literal, List, Optional + +from python_toolkit.bhom_tkinter.bhom_window_shell import BHoMWindowShell +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class BHoMBaseChildWindow(tk.Toplevel, BHoMWindowShell): + """Themed child window for modals and standalone dialogs.""" + + def __init__( + self, + parent: tk.Misc | None = None, + *, + title: str = "Application", + min_width: int = 400, + min_height: int = 400, + width: Optional[int] = None, + height: Optional[int] = None, + resizable: bool = True, + center_on_screen: bool = True, + show_submit: bool = True, + submit_text: str = "Submit", + submit_command: Optional[Callable] = None, + close_on_submit: bool = True, + show_close: bool = True, + close_text: str = "Close", + close_command: Optional[Callable] = None, + on_close_window: Optional[Callable] = None, + theme_mode: str | None = None, + widgets: Optional[List[BHoMBaseWidget]] = None, + top_most: bool = False, + fullscreen: bool = False, + buttons_side: Literal["left", "right"] = "right", + grid_dimensions: Optional[tuple[int, int]] = None, + show_banner: bool = False, + defer_show: bool = False, + content_padding: int = 20, + modal: bool = False, + **kwargs, + ) -> None: + self._standalone_root: tk.Tk | None = None + self._modal = modal + self._modal_closed = False + + if parent is None: + self._standalone_root = tk.Tk() + self._standalone_root.withdraw() + parent = self._standalone_root + elif theme_mode is None and hasattr(parent, "theme"): + theme_mode = "dark" if getattr(parent.theme, "dark_theme", False) else "light" + + if theme_mode is None: + theme_mode = "auto" + + super().__init__(parent, **kwargs) + if parent is not self._standalone_root: + self.transient(parent) + + self._init_bhom_shell( + title=title, + min_width=min_width, + min_height=min_height, + width=width, + height=height, + resizable=resizable, + center_on_screen=center_on_screen, + show_submit=show_submit, + submit_text=submit_text, + submit_command=submit_command, + close_on_submit=close_on_submit, + show_close=show_close, + close_text=close_text, + close_command=close_command, + on_close_window=on_close_window, + theme_mode=theme_mode, + widgets=widgets, + top_most=top_most, + fullscreen=fullscreen, + buttons_side=buttons_side, + grid_dimensions=grid_dimensions, + show_banner=show_banner, + defer_show=defer_show, + content_padding=content_padding, + ) + + if modal: + self.protocol("WM_DELETE_WINDOW", self.close) + + host = parent if parent is not self._standalone_root else None + if host is not None and hasattr(host, "theme"): + self.theme = host.theme + self._load_theme() + self._set_window_icon() + + def mainloop(self, n: int = 0) -> None: + """Run the event loop for standalone dialogs, or block until closed.""" + if self._standalone_root is not None: + self._standalone_root.mainloop(n) + return + self.wait_window() + + def show(self) -> None: + """Display the child window and optionally capture input.""" + self._show_window_with_styling() + if self._modal: + self.grab_set() + try: + self.focus_force() + except Exception: + pass + + def run(self) -> None: + """Show a modal child window and block until it closes.""" + self.show() + self.wait_window() + + def close(self) -> None: + """Close a modal child window and release the input grab.""" + if self._modal_closed: + return + self._modal_closed = True + self._on_close() + + def destroy_root(self) -> None: + """Destroy the child window without stopping a host application loop.""" + try: + self.grab_release() + except Exception: + pass + try: + if self.winfo_exists(): + self.destroy() + except tk.TclError: + pass + if self._standalone_root is not None: + try: + self._standalone_root.quit() + except Exception: + pass + try: + self._standalone_root.destroy() + except tk.TclError: + pass diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index 8318e655..7a37b35f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -25,8 +25,9 @@ from python_toolkit.bhom_tkinter.widgets.button import Button import python_toolkit from python_toolkit.bhom_tkinter.theming.theme import ThemeManager +from python_toolkit.bhom_tkinter.bhom_window_shell import BHoMWindowShell -class BHoMBaseWindow(tk.Tk): +class BHoMBaseWindow(tk.Tk, BHoMWindowShell): """ A reusable default root window template for tkinter applications. Includes a branded banner, content area, and optional action buttons. @@ -55,6 +56,9 @@ def __init__( fullscreen: bool = False, buttons_side: Literal["left", "right"] = "right", grid_dimensions: Optional[tuple[int, int]] = None, + show_banner: bool = True, + defer_show: bool = False, + content_padding: int = 20, **kwargs ): """ @@ -82,571 +86,38 @@ def __init__( fullscreen (bool): Whether the window starts in fullscreen mode (default: False). buttons_side (str): Side for buttons - "left" or "right" (default: "right"). grid_dimensions (tuple[int, int], optional): If provided, configures content area with specified rows and columns for grid layout. + show_banner (bool): Whether to render the branded banner header (default: True). + defer_show (bool): If True, size the window but stay withdrawn until shown manually. + content_padding (int): Padding applied to the content frame. **kwargs """ super().__init__(**kwargs) - self.title(title) - self._icon_image = None - self.minsize(min_width, min_height) - self.resizable(resizable, resizable) - - self.top_most = top_most - self.attributes("-topmost", True) - if not self.top_most: - self.after(0, lambda: self.attributes("-topmost", False)) - - self.fullscreen = fullscreen - - # Avoid sharing widget instances across windows/runs. - self.widgets = list(widgets) if widgets is not None else [] - - # Hide window during setup to prevent flash - self.withdraw() - - - self.theme = ThemeManager(theme_mode) - self._load_theme() - self._set_window_icon() - - self.min_width = min_width - self.min_height = min_height - self.fixed_width = width - self.fixed_height = height - self.center_on_screen = center_on_screen - self.submit_command = submit_command - self.close_on_submit = close_on_submit - self.close_command = close_command - self.result = None - self._is_exiting = False - self.button_bar: Optional[ttk.Frame] = None - self._has_been_shown = False - self._pending_resize_job: Optional[str] = None - self._is_resizing = False - self._rigid_width = width is not None - self._rigid_height = height is not None - self._auto_fit_width = width is None - self._auto_fit_height = height is None - self._post_show_size_applied = False - self.grid_dimensions = grid_dimensions - self._cached_widget_values: dict[str, object] = {} - - # Handle window close (X button) - self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) - - # Main container - self.main_container = ttk.Frame(self) - self.main_container.pack(fill=tk.BOTH, expand=True) - - # Banner section - self._build_banner(self.main_container, title, self.theme.logo_path) - - # Content area (public access for adding widgets) - self.content_frame = ttk.Frame(self.main_container, padding=20) - self.content_frame.pack(fill=tk.BOTH, expand=True) - - if self.grid_dimensions: - self.grid_content_frame(*self.grid_dimensions) - - # Bottom button frame (if needed) - if show_submit or show_close: - self._build_buttons(self.main_container, show_submit, submit_text, show_close, close_text, buttons_side) - - self._bind_dynamic_sizing() - - # Apply sizing - self._apply_sizing() - self.build() - - def grid_content_frame(self, x_count: int, y_count: int) -> None: - """Configure the content frame with a grid layout of specified dimensions.""" - self.grid_dimensions = (x_count, y_count) - for r in range(y_count): - self.content_frame.rowconfigure(r, weight=1) - for c in range(x_count): - self.content_frame.columnconfigure(c, weight=1) - - def build(self): - """Call build on all child widgets that have it (for deferred widget construction).""" - - if any(not isinstance(w, BHoMBaseWidget) for w in self.widgets): - raise TypeError("All items in widgets list must be instances of BHoMBaseWidget.") - - for widget in self.widgets: - widget.build() - - self.refresh_sizing() - - def _set_window_icon(self) -> None: - """Set a custom window icon, replacing Tk's default icon.""" - icon_path = self.theme.icon_path - - if not icon_path: - return - - # Windows prefers .ico for titlebar/taskbar icons. - if icon_path.suffix.lower() == ".ico": - try: - self.iconbitmap(default=str(icon_path)) - return - except tk.TclError: - pass - - # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). - try: - self._icon_image = tk.PhotoImage(file=str(icon_path)) - self.iconphoto(True, self._icon_image) - return - except tk.TclError: - pass - - except Exception as ex: - print(f"Warning: Could not set window icon from {icon_path}: {ex}") - - def _set_titlebar_theme(self) -> None: - """ - Apply titlebar theme using Windows API. - - Args: - theme_style: Theme style key (`light` or `dark`). - - Returns: - None - """ - try: - - use_dark = 1 if self.theme.dark_theme else 0 - - if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): - hwnd = self.winfo_id() - hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) - if hwnd: - DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - ctypes.windll.dwmapi.DwmSetWindowAttribute( - hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, - ctypes.byref(ctypes.c_int(use_dark)), - ctypes.sizeof(ctypes.c_int) - ) - - except Exception: - pass - - def _load_theme(self) -> str: - """ - Load a custom theme from a TCL file. - - Args: - custom_theme_path (Path, optional): Path to custom TCL theme file. - If None, uses default style.tcl in same directory. - theme_name (str): Name of the theme to apply from the TCL file. - - Returns: - str: Name of the theme that ended up being applied. - """ - style = ttk.Style() - - try: - current_themes = set(style.theme_names()) - - expected_theme = self.theme.path.stem.replace("_theme", "") - # Load the TCL theme file - try: - self.tk.call('source', str(self.theme.path)) - except tk.TclError as source_error: - if "already exists" not in str(source_error).lower(): - raise - - available_theme_names = style.theme_names() - newly_added = [name for name in available_theme_names if name not in current_themes] - if expected_theme in available_theme_names: - selected_theme = expected_theme - elif newly_added: - selected_theme = newly_added[-1] - else: - selected_theme = style.theme_use() if available_theme_names else "default" - - style.theme_use(selected_theme) - self._ensure_typography_styles(style) - return selected_theme - - except Exception as e: - print(f"Warning: Could not load custom theme: {e}") - try: - active_theme = style.theme_use() - self._ensure_typography_styles(style) - return active_theme - except Exception: - return "default" - - def _ensure_typography_styles(self, style: ttk.Style) -> None: - """Ensure key typography styles exist and remain visually distinct.""" - defaults = { - "TLabel": ("Segoe UI", 10, "bold"), - "Body.TLabel": ("Segoe UI", 10), - "Caption.TLabel": ("Segoe UI", 9), - "Small.TLabel": ("Segoe UI", 8), - "Heading.TLabel": ("Segoe UI", 12, "bold"), - "Subtitle.TLabel": ("Segoe UI", 14, "bold"), - "Headline.TLabel": ("Segoe UI", 16, "bold"), - "Title.TLabel": ("Segoe UI", 24, "bold"), - "LargeTitle.TLabel": ("Segoe UI", 24, "bold"), - "Display.TLabel": ("Segoe UI", 28, "bold"), - } - - def _lookup_font(style_name: str) -> str: - try: - return str(style.lookup(style_name, "font") or "").strip() - except Exception: - return "" - - for style_name, font_spec in defaults.items(): - if not _lookup_font(style_name): - try: - style.configure(style_name, font=font_spec) - except Exception: - pass - - base_font = _lookup_font("TLabel") - for style_name, font_spec in ( - ("Caption.TLabel", defaults["Caption.TLabel"]), - ("Subtitle.TLabel", defaults["Subtitle.TLabel"]), - ("Headline.TLabel", defaults["Headline.TLabel"]), - ("LargeTitle.TLabel", defaults["LargeTitle.TLabel"]), - ): - resolved = _lookup_font(style_name) - if not resolved or resolved == base_font: - try: - style.configure(style_name, font=font_spec) - except Exception: - pass - - def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: - """Build the branded banner section. - - Args: - parent: Parent frame to host the banner. - title: Banner title text. - logo_path: Optional logo image path. - """ - banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) - banner.pack(fill=tk.BOTH, padx=0, pady=0) - - banner_content = ttk.Frame(banner, padding=10) - banner_content.pack(fill=tk.BOTH, expand=True) - - # Text container - text_container = ttk.Frame(banner_content) - text_container.pack(side=tk.LEFT, fill=tk.Y) - - logo_container = ttk.Frame(banner_content, width=80) - logo_container.pack(side=tk.RIGHT, fill=tk.Y) - - # Logo (if provided) - if logo_path and logo_path.exists(): - try: - from PIL import Image, ImageTk - img = Image.open(logo_path) - img.thumbnail((80, 80), Image.Resampling.LANCZOS) - # Bind image to this root explicitly to avoid stale image handles - # when previous runs failed and tore down a different Tk interpreter. - self.logo_image = ImageTk.PhotoImage(img, master=self) - logo_label = Label(logo_container, image=self.logo_image) - logo_label.pack(fill=tk.BOTH, expand=True) - except tk.TclError: - pass - except ImportError: - pass # PIL not available, skip logo - - # Title - title_label = Label( - text_container, - text=title, - style="LargeTitle.TLabel" + self._init_bhom_shell( + title=title, + min_width=min_width, + min_height=min_height, + width=width, + height=height, + resizable=resizable, + center_on_screen=center_on_screen, + show_submit=show_submit, + submit_text=submit_text, + submit_command=submit_command, + close_on_submit=close_on_submit, + show_close=show_close, + close_text=close_text, + close_command=close_command, + on_close_window=on_close_window, + theme_mode=theme_mode, + widgets=widgets, + top_most=top_most, + fullscreen=fullscreen, + buttons_side=buttons_side, + grid_dimensions=grid_dimensions, + show_banner=show_banner, + defer_show=defer_show, + content_padding=content_padding, ) - title_label.pack(anchor="w") - - # Subtitle - subtitle_label = Label( - text_container, - text="powered by BHoM", - style="Caption.TLabel" - ) - subtitle_label.pack(anchor="w") - - def _build_buttons( - self, - parent: ttk.Frame, - show_submit: bool, - submit_text: str, - show_close: bool, - close_text: str, - buttons_side: Literal["left", "right"] = "right" - ) -> None: - """Build the bottom button bar. - - Args: - parent: Parent frame for the button bar. - show_submit: Whether to create submit button. - submit_text: Submit button label. - show_close: Whether to create close button. - close_text: Close button label. - """ - self.button_bar = ttk.Frame(parent, padding=(20, 10)) - self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) - - button_container = ttk.Frame(self.button_bar) - button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) - - if show_submit: - submit_widget = Button( - button_container, - text=submit_text, - command=self._on_submit, - style="Primary.TButton", - width=12, - alignment="center", - ) - submit_widget.pack(side=tk.LEFT, padx=5) - # expose inner ttk.Button for compatibility - self.submit_button = submit_widget.button - - if show_close: - close_widget = Button( - button_container, - text=close_text, - command=self._on_close, - width=12, - alignment="center", - ) - close_widget.pack(side=tk.LEFT, padx=5) - # expose inner ttk.Button for compatibility - self.close_button = close_widget.button - - def _bind_dynamic_sizing(self) -> None: - """Bind layout changes to schedule auto sizing updates.""" - self.main_container.bind("", self._schedule_dynamic_sizing) - self.content_frame.bind("", self._schedule_dynamic_sizing) - if self.button_bar is not None: - self.button_bar.bind("", self._schedule_dynamic_sizing) - - def _schedule_dynamic_sizing(self, _event=None) -> None: - """Debounce dynamic sizing updates triggered by layout changes.""" - # Avoid fighting user-driven manual resize after the window is visible. - # Initial sizing is handled by `_apply_sizing` + one post-show pass. - if self._has_been_shown: - return - if self._is_resizing: - return - if not (self._auto_fit_width or self._auto_fit_height): - return - if self._pending_resize_job is not None: - try: - self.after_cancel(self._pending_resize_job) - except Exception: - pass - self._pending_resize_job = self.after(30, self._apply_sizing) - - def _apply_sizing(self) -> None: - """Apply window sizing and positioning.""" - self._pending_resize_job = None - self._is_resizing = True - self.update_idletasks() - - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - if hasattr(self, "main_container"): - required_width = max(required_width, self.main_container.winfo_reqwidth()) - required_height = max(required_height, self.main_container.winfo_reqheight()) - - if self.button_bar is not None and self.button_bar.winfo_manager(): - required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) - - # Determine final dimensions: auto-size unless a rigid dimension is explicitly provided. - if self._rigid_width: - final_width = max(self.min_width, int(self.fixed_width or 0)) - else: - final_width = max(self.min_width, required_width) - - if self._rigid_height: - final_height = max(self.min_height, int(self.fixed_height or 0)) - else: - final_height = max(self.min_height, required_height) - - # Fullscreen overrides normal sizing/positioning - if self.fullscreen: - self.attributes("-fullscreen", True) - self.after(0, self._show_window_with_styling) - self._is_resizing = False - return - - # Position - if self.center_on_screen and not self._has_been_shown: - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - x = (screen_width - final_width) // 2 - y = (screen_height - final_height) // 2 - self.geometry(f"{final_width}x{final_height}+{x}+{y}") - elif self._has_been_shown: - x = self.winfo_x() - y = self.winfo_y() - self.geometry(f"{final_width}x{final_height}+{x}+{y}") - else: - self.geometry(f"{final_width}x{final_height}") - - # Defer window display until after styling is applied - self.after(0, self._show_window_with_styling) - self._is_resizing = False - - def _apply_post_show_sizing(self) -> None: - """Run one extra grow-only size pass after first show. - - On some Windows setups, control metrics settle after deiconify/theme - application, which can under-estimate initial required height and clip - bottom controls. - """ - if self._post_show_size_applied: - return - - self.update_idletasks() - - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - if hasattr(self, "main_container"): - required_width = max(required_width, self.main_container.winfo_reqwidth()) - required_height = max(required_height, self.main_container.winfo_reqheight()) - - if self.button_bar is not None and self.button_bar.winfo_manager(): - required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) - - current_width = self.winfo_width() - current_height = self.winfo_height() - - target_width = current_width - target_height = current_height - - if self._auto_fit_width: - target_width = max(current_width, self.min_width, required_width) - elif self._rigid_width and self.fixed_width is not None: - target_width = max(self.min_width, int(self.fixed_width)) - - if self._auto_fit_height: - target_height = max(current_height, self.min_height, required_height) - elif self._rigid_height and self.fixed_height is not None: - target_height = max(self.min_height, int(self.fixed_height)) - - if target_width != current_width or target_height != current_height: - x = self.winfo_x() - y = self.winfo_y() - self.geometry(f"{target_width}x{target_height}+{x}+{y}") - - self._post_show_size_applied = True - - def _show_window_with_styling(self) -> None: - """Apply titlebar styling and show the window.""" - self._set_titlebar_theme() - - # Show window after styling - self.deiconify() - self._has_been_shown = True - if not self._post_show_size_applied and (self._auto_fit_width or self._auto_fit_height): - self.after_idle(self._apply_post_show_sizing) - - def refresh_sizing(self) -> None: - """Recalculate and apply window sizing (useful after adding widgets).""" - self._apply_sizing() - - def close(self) -> None: - """Close and destroy the window. Override in subclasses for custom close behaviour.""" - self.destroy_root() - - def destroy_root(self) -> None: - """Safely terminate and destroy the Tk root window.""" - - try: - if self.winfo_exists(): - self.quit() - self.destroy() - except tk.TclError: - pass - - def _exit(self, result: str, callback: Optional[Callable] = None) -> None: - """Handle any exit path and always destroy the root window. - - Args: - result: Result token to store before closing. - callback: Optional callback invoked before destruction. - """ - if self._is_exiting: - return - self._is_exiting = True - self.result = result - try: - if callback: - callback() - except tk.TclError as ex: - message = str(ex).lower() - if not ("image" in message and "doesn't exist" in message): - print(f"Warning: Exit callback raised an exception: {ex}") - except Exception as ex: - print(f"Warning: Exit callback raised an exception: {ex}") - finally: - # Capture values while widgets still exist so `get()` remains usable - # after root teardown. - self._cached_widget_values = self._collect_widget_values() - self.destroy_root() - - def _on_submit(self) -> None: - """Handle submit button click.""" - if self.close_on_submit: - self._exit("submit", self.submit_command) - return - - self.result = "submit" - try: - if self.submit_command: - self.submit_command() - except tk.TclError as ex: - message = str(ex).lower() - if not ("image" in message and "doesn't exist" in message): - print(f"Warning: Exit callback raised an exception: {ex}") - except Exception as ex: - print(f"Warning: Exit callback raised an exception: {ex}") - finally: - self._cached_widget_values = self._collect_widget_values() - - - def _on_close(self) -> None: - """Handle close button click.""" - self._exit("close", self.close_command) - - def _on_close_window(self, callback: Optional[Callable]) -> None: - """Handle window X button click.""" - self._exit("window_closed", callback) - - def get(self): - try: - if not self.winfo_exists(): - return dict(self._cached_widget_values) - except Exception: - return dict(self._cached_widget_values) - - widget_values = self._collect_widget_values() - self._cached_widget_values = dict(widget_values) - return widget_values - - def _collect_widget_values(self) -> dict[str, object]: - """Collect values from all registered widgets.""" - widget_values: dict[str, object] = {} - - for widget in self.widgets: - - if hasattr(widget, "get"): - try: - widget_values[widget.id] = widget.get() - except Exception as ex: - print(f"Warning: Failed to get value from widget {widget}: {ex}") - return widget_values if __name__ == "__main__": diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py new file mode 100644 index 00000000..0b922bbf --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py @@ -0,0 +1,612 @@ +"""Shared themed window chrome mixin for BHoM root and child windows.""" + +import tkinter as tk +from tkinter import ttk +from python_toolkit.bhom_tkinter.widgets.label import Label +from pathlib import Path +from typing import Optional, Callable, Literal, List +import platform +import ctypes +import python_toolkit +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.theming.theme import ThemeManager + + +class BHoMWindowShell: + """Shared themed window chrome for BHoM Tk root and Toplevel windows.""" + + def _init_bhom_shell( + self, + *, + title: str, + min_width: int, + min_height: int, + width: Optional[int], + height: Optional[int], + resizable: bool, + center_on_screen: bool, + show_submit: bool, + submit_text: str, + submit_command: Optional[Callable], + close_on_submit: bool, + show_close: bool, + close_text: str, + close_command: Optional[Callable], + on_close_window: Optional[Callable], + theme_mode: str, + widgets: Optional[List[BHoMBaseWidget]], + top_most: bool, + fullscreen: bool, + buttons_side: Literal["left", "right"], + grid_dimensions: Optional[tuple[int, int]], + show_banner: bool = True, + defer_show: bool = False, + content_padding: int = 20, + ) -> None: + self.defer_show = defer_show + self.title(title) + self._icon_image = None + self.minsize(min_width, min_height) + self.resizable(resizable, resizable) + + self.top_most = top_most + self.attributes("-topmost", True) + if not self.top_most: + self.after(0, lambda: self.attributes("-topmost", False)) + + self.fullscreen = fullscreen + self.widgets = list(widgets) if widgets is not None else [] + self.withdraw() + + self.theme = ThemeManager(theme_mode) + self._load_theme() + self._set_window_icon() + + self.min_width = min_width + self.min_height = min_height + self.fixed_width = width + self.fixed_height = height + self.center_on_screen = center_on_screen + self.submit_command = submit_command + self.close_on_submit = close_on_submit + self.close_command = close_command + self.result = None + self._is_exiting = False + self.button_bar: Optional[ttk.Frame] = None + self._has_been_shown = False + self._pending_resize_job: Optional[str] = None + self._is_resizing = False + self._rigid_width = width is not None + self._rigid_height = height is not None + self._auto_fit_width = width is None + self._auto_fit_height = height is None + self._post_show_size_applied = False + self.grid_dimensions = grid_dimensions + self._cached_widget_values: dict[str, object] = {} + + self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) + + self.main_container = ttk.Frame(self) + self.main_container.pack(fill=tk.BOTH, expand=True) + + if show_banner: + self._build_banner(self.main_container, title, self.theme.logo_path) + + self.content_frame = ttk.Frame(self.main_container, padding=content_padding) + self.content_frame.pack(fill=tk.BOTH, expand=True) + + if self.grid_dimensions: + self.grid_content_frame(*self.grid_dimensions) + + if show_submit or show_close: + self._build_buttons( + self.main_container, + show_submit, + submit_text, + show_close, + close_text, + buttons_side, + ) + + self._bind_dynamic_sizing() + self._apply_sizing() + self.build() + + def grid_content_frame(self, x_count: int, y_count: int) -> None: + """Configure the content frame with a grid layout of specified dimensions.""" + self.grid_dimensions = (x_count, y_count) + for r in range(y_count): + self.content_frame.rowconfigure(r, weight=1) + for c in range(x_count): + self.content_frame.columnconfigure(c, weight=1) + + def build(self): + """Call build on all child widgets that have it (for deferred widget construction).""" + + if any(not isinstance(w, BHoMBaseWidget) for w in self.widgets): + raise TypeError("All items in widgets list must be instances of BHoMBaseWidget.") + + for widget in self.widgets: + widget.build() + + self.refresh_sizing() + + def _set_window_icon(self) -> None: + """Set a custom window icon, replacing Tk's default icon.""" + icon_path = self.theme.icon_path + + if not icon_path: + return + + # Windows prefers .ico for titlebar/taskbar icons. + if icon_path.suffix.lower() == ".ico": + try: + self.iconbitmap(default=str(icon_path)) + return + except tk.TclError: + pass + + # Fallback for image formats supported by Tk PhotoImage (png/gif/etc.). + try: + self._icon_image = tk.PhotoImage(file=str(icon_path)) + self.iconphoto(True, self._icon_image) + return + except tk.TclError: + pass + + except Exception as ex: + print(f"Warning: Could not set window icon from {icon_path}: {ex}") + + def _set_titlebar_theme(self) -> None: + """ + Apply titlebar theme using Windows API. + + Args: + theme_style: Theme style key (`light` or `dark`). + + Returns: + None + """ + try: + + use_dark = 1 if self.theme.dark_theme else 0 + + if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): + hwnd = self.winfo_id() + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + if hwnd: + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(use_dark)), + ctypes.sizeof(ctypes.c_int) + ) + + except Exception: + pass + + def _load_theme(self) -> str: + """ + Load a custom theme from a TCL file. + + Args: + custom_theme_path (Path, optional): Path to custom TCL theme file. + If None, uses default style.tcl in same directory. + theme_name (str): Name of the theme to apply from the TCL file. + + Returns: + str: Name of the theme that ended up being applied. + """ + style = ttk.Style() + + try: + current_themes = set(style.theme_names()) + + expected_theme = self.theme.path.stem.replace("_theme", "") + # Load the TCL theme file + try: + self.tk.call('source', str(self.theme.path)) + except tk.TclError as source_error: + if "already exists" not in str(source_error).lower(): + raise + + available_theme_names = style.theme_names() + newly_added = [name for name in available_theme_names if name not in current_themes] + if expected_theme in available_theme_names: + selected_theme = expected_theme + elif newly_added: + selected_theme = newly_added[-1] + else: + selected_theme = style.theme_use() if available_theme_names else "default" + + style.theme_use(selected_theme) + self._ensure_typography_styles(style) + return selected_theme + + except Exception as e: + print(f"Warning: Could not load custom theme: {e}") + try: + active_theme = style.theme_use() + self._ensure_typography_styles(style) + return active_theme + except Exception: + return "default" + + def _ensure_typography_styles(self, style: ttk.Style) -> None: + """Ensure key typography styles exist and remain visually distinct.""" + defaults = { + "TLabel": ("Segoe UI", 10, "bold"), + "Body.TLabel": ("Segoe UI", 10), + "Caption.TLabel": ("Segoe UI", 9), + "Small.TLabel": ("Segoe UI", 8), + "Heading.TLabel": ("Segoe UI", 12, "bold"), + "Subtitle.TLabel": ("Segoe UI", 14, "bold"), + "Headline.TLabel": ("Segoe UI", 16, "bold"), + "Title.TLabel": ("Segoe UI", 24, "bold"), + "LargeTitle.TLabel": ("Segoe UI", 24, "bold"), + "Display.TLabel": ("Segoe UI", 28, "bold"), + } + + def _lookup_font(style_name: str) -> str: + try: + return str(style.lookup(style_name, "font") or "").strip() + except Exception: + return "" + + for style_name, font_spec in defaults.items(): + if not _lookup_font(style_name): + try: + style.configure(style_name, font=font_spec) + except Exception: + pass + + base_font = _lookup_font("TLabel") + for style_name, font_spec in ( + ("Caption.TLabel", defaults["Caption.TLabel"]), + ("Subtitle.TLabel", defaults["Subtitle.TLabel"]), + ("Headline.TLabel", defaults["Headline.TLabel"]), + ("LargeTitle.TLabel", defaults["LargeTitle.TLabel"]), + ): + resolved = _lookup_font(style_name) + if not resolved or resolved == base_font: + try: + style.configure(style_name, font=font_spec) + except Exception: + pass + + def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path]) -> None: + """Build the branded banner section. + + Args: + parent: Parent frame to host the banner. + title: Banner title text. + logo_path: Optional logo image path. + """ + banner = ttk.Frame(parent, relief=tk.RIDGE, borderwidth=1) + banner.pack(fill=tk.BOTH, padx=0, pady=0) + + banner_content = ttk.Frame(banner, padding=10) + banner_content.pack(fill=tk.BOTH, expand=True) + + # Text container + text_container = ttk.Frame(banner_content) + text_container.pack(side=tk.LEFT, fill=tk.Y) + + logo_container = ttk.Frame(banner_content, width=80) + logo_container.pack(side=tk.RIGHT, fill=tk.Y) + + # Logo (if provided) + if logo_path and logo_path.exists(): + try: + from PIL import Image, ImageTk + img = Image.open(logo_path) + img.thumbnail((80, 80), Image.Resampling.LANCZOS) + # Bind image to this root explicitly to avoid stale image handles + # when previous runs failed and tore down a different Tk interpreter. + self.logo_image = ImageTk.PhotoImage(img, master=self) + logo_label = Label(logo_container, image=self.logo_image) + logo_label.pack(fill=tk.BOTH, expand=True) + except tk.TclError: + pass + except ImportError: + pass # PIL not available, skip logo + + # Title + title_label = Label( + text_container, + text=title, + style="LargeTitle.TLabel" + ) + title_label.pack(anchor="w") + + # Subtitle + subtitle_label = Label( + text_container, + text="powered by BHoM", + style="Caption.TLabel" + ) + subtitle_label.pack(anchor="w") + + def _build_buttons( + self, + parent: ttk.Frame, + show_submit: bool, + submit_text: str, + show_close: bool, + close_text: str, + buttons_side: Literal["left", "right"] = "right" + ) -> None: + """Build the bottom button bar. + + Args: + parent: Parent frame for the button bar. + show_submit: Whether to create submit button. + submit_text: Submit button label. + show_close: Whether to create close button. + close_text: Close button label. + """ + self.button_bar = ttk.Frame(parent, padding=(20, 10)) + self.button_bar.pack(side=tk.BOTTOM, fill=tk.X) + + button_container = ttk.Frame(self.button_bar) + button_container.pack(anchor=tk.E if buttons_side == "right" else tk.W) + + if show_submit: + submit_widget = Button( + button_container, + text=submit_text, + command=self._on_submit, + style="Primary.TButton", + width=12, + alignment="center", + ) + submit_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.submit_button = submit_widget.button + + if show_close: + close_widget = Button( + button_container, + text=close_text, + command=self._on_close, + width=12, + alignment="center", + ) + close_widget.pack(side=tk.LEFT, padx=5) + # expose inner ttk.Button for compatibility + self.close_button = close_widget.button + + def _bind_dynamic_sizing(self) -> None: + """Bind layout changes to schedule auto sizing updates.""" + self.main_container.bind("", self._schedule_dynamic_sizing) + self.content_frame.bind("", self._schedule_dynamic_sizing) + if self.button_bar is not None: + self.button_bar.bind("", self._schedule_dynamic_sizing) + + def _schedule_dynamic_sizing(self, _event=None) -> None: + """Debounce dynamic sizing updates triggered by layout changes.""" + # Avoid fighting user-driven manual resize after the window is visible. + # Initial sizing is handled by `_apply_sizing` + one post-show pass. + if self._has_been_shown: + return + if self._is_resizing: + return + if not (self._auto_fit_width or self._auto_fit_height): + return + if self._pending_resize_job is not None: + try: + self.after_cancel(self._pending_resize_job) + except Exception: + pass + self._pending_resize_job = self.after(30, self._apply_sizing) + + def _apply_sizing(self) -> None: + """Apply window sizing and positioning.""" + self._pending_resize_job = None + self._is_resizing = True + self.update_idletasks() + + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() + + if hasattr(self, "main_container"): + required_width = max(required_width, self.main_container.winfo_reqwidth()) + required_height = max(required_height, self.main_container.winfo_reqheight()) + + if self.button_bar is not None and self.button_bar.winfo_manager(): + required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) + + # Determine final dimensions: auto-size unless a rigid dimension is explicitly provided. + if self._rigid_width: + final_width = max(self.min_width, int(self.fixed_width or 0)) + else: + final_width = max(self.min_width, required_width) + + if self._rigid_height: + final_height = max(self.min_height, int(self.fixed_height or 0)) + else: + final_height = max(self.min_height, required_height) + + # Fullscreen overrides normal sizing/positioning + if self.fullscreen: + self.attributes("-fullscreen", True) + if not getattr(self, "defer_show", False): + self.after(0, self._show_window_with_styling) + self._is_resizing = False + return + + # Position + if self.center_on_screen and not self._has_been_shown: + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + x = (screen_width - final_width) // 2 + y = (screen_height - final_height) // 2 + self.geometry(f"{final_width}x{final_height}+{x}+{y}") + elif self._has_been_shown: + x = self.winfo_x() + y = self.winfo_y() + self.geometry(f"{final_width}x{final_height}+{x}+{y}") + else: + self.geometry(f"{final_width}x{final_height}") + + if getattr(self, "defer_show", False): + self._is_resizing = False + return + + # Defer window display until after styling is applied + self.after(0, self._show_window_with_styling) + self._is_resizing = False + + def _apply_post_show_sizing(self) -> None: + """Run one extra grow-only size pass after first show. + + On some Windows setups, control metrics settle after deiconify/theme + application, which can under-estimate initial required height and clip + bottom controls. + """ + if self._post_show_size_applied: + return + + self.update_idletasks() + + required_width = self.winfo_reqwidth() + required_height = self.winfo_reqheight() + + if hasattr(self, "main_container"): + required_width = max(required_width, self.main_container.winfo_reqwidth()) + required_height = max(required_height, self.main_container.winfo_reqheight()) + + if self.button_bar is not None and self.button_bar.winfo_manager(): + required_height = max(required_height, self.button_bar.winfo_reqheight() + self.content_frame.winfo_reqheight()) + + current_width = self.winfo_width() + current_height = self.winfo_height() + + target_width = current_width + target_height = current_height + + if self._auto_fit_width: + target_width = max(current_width, self.min_width, required_width) + elif self._rigid_width and self.fixed_width is not None: + target_width = max(self.min_width, int(self.fixed_width)) + + if self._auto_fit_height: + target_height = max(current_height, self.min_height, required_height) + elif self._rigid_height and self.fixed_height is not None: + target_height = max(self.min_height, int(self.fixed_height)) + + if target_width != current_width or target_height != current_height: + x = self.winfo_x() + y = self.winfo_y() + self.geometry(f"{target_width}x{target_height}+{x}+{y}") + + self._post_show_size_applied = True + + def _show_window_with_styling(self) -> None: + """Apply titlebar styling and show the window.""" + self._set_titlebar_theme() + + # Show window after styling + self.deiconify() + self._has_been_shown = True + if not self._post_show_size_applied and (self._auto_fit_width or self._auto_fit_height): + self.after_idle(self._apply_post_show_sizing) + + def refresh_sizing(self) -> None: + """Recalculate and apply window sizing (useful after adding widgets).""" + self._apply_sizing() + + def close(self) -> None: + """Close and destroy the window. Override in subclasses for custom close behaviour.""" + self.destroy_root() + + def destroy_root(self) -> None: + """Safely terminate and destroy the Tk root window.""" + + try: + if self.winfo_exists(): + self.quit() + self.destroy() + except tk.TclError: + pass + + def _exit(self, result: str, callback: Optional[Callable] = None) -> None: + """Handle any exit path and always destroy the root window. + + Args: + result: Result token to store before closing. + callback: Optional callback invoked before destruction. + """ + if self._is_exiting: + return + self._is_exiting = True + self.result = result + try: + if callback: + callback() + except tk.TclError as ex: + message = str(ex).lower() + if not ("image" in message and "doesn't exist" in message): + print(f"Warning: Exit callback raised an exception: {ex}") + except Exception as ex: + print(f"Warning: Exit callback raised an exception: {ex}") + finally: + # Capture values while widgets still exist so `get()` remains usable + # after root teardown. + self._cached_widget_values = self._collect_widget_values() + self.destroy_root() + + def _on_submit(self) -> None: + """Handle submit button click.""" + if self.close_on_submit: + self._exit("submit", self.submit_command) + return + + self.result = "submit" + try: + if self.submit_command: + self.submit_command() + except tk.TclError as ex: + message = str(ex).lower() + if not ("image" in message and "doesn't exist" in message): + print(f"Warning: Exit callback raised an exception: {ex}") + except Exception as ex: + print(f"Warning: Exit callback raised an exception: {ex}") + finally: + self._cached_widget_values = self._collect_widget_values() + + + def _on_close(self) -> None: + """Handle close button click.""" + self._exit("close", self.close_command) + + def _on_close_window(self, callback: Optional[Callable]) -> None: + """Handle window X button click.""" + self._exit("window_closed", callback) + + def get(self): + try: + if not self.winfo_exists(): + return dict(self._cached_widget_values) + except Exception: + return dict(self._cached_widget_values) + + widget_values = self._collect_widget_values() + self._cached_widget_values = dict(widget_values) + return widget_values + + def _collect_widget_values(self) -> dict[str, object]: + """Collect values from all registered widgets.""" + widget_values: dict[str, object] = {} + + for widget in self.widgets: + + if hasattr(widget, "get"): + try: + widget_values[widget.id] = widget.get() + except Exception as ex: + print(f"Warning: Failed to get value from widget {widget}: {ex}") + return widget_values + + diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py index c372f4bb..cdd1dabd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py @@ -1,9 +1,13 @@ +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow from .directory_file_selector import DirectoryFileSelector from .landing_page import LandingPage +from .modal_window import BHoMModalWindow from .processing_window import ProcessingWindow from .warning_box import WarningBox __all__ = [ + "BHoMBaseChildWindow", + "BHoMModalWindow", "DirectoryFileSelector", "LandingPage", "ProcessingWindow", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py index 67e9caba..f708d537 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/directory_file_selector.py @@ -8,9 +8,9 @@ from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow -class DirectoryFileSelector(BHoMBaseWindow): +class DirectoryFileSelector(BHoMBaseChildWindow): """Display matching files and return the user's multi-selection.""" def __init__( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py new file mode 100644 index 00000000..9ed71d82 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/modal_window.py @@ -0,0 +1,50 @@ +"""Themed modal dialog window for child forms on a BHoM parent window.""" + +from __future__ import annotations + +import tkinter as tk +from typing import Callable, Optional + +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow + + +class BHoMModalWindow(BHoMBaseChildWindow): + """Modal child window with BHoM theming and a content area.""" + + def __init__( + self, + parent: tk.Misc, + *, + title: str, + width: int | None = None, + height: int | None = None, + min_width: int = 320, + min_height: int = 240, + resizable: bool = False, + show_close: bool = True, + close_text: str = "Close", + close_command: Optional[Callable[[], None]] = None, + theme_mode: str | None = None, + content_padding: int = 20, + **kwargs, + ) -> None: + super().__init__( + parent, + title=title, + width=width, + height=height, + min_width=min_width, + min_height=min_height, + resizable=resizable, + show_submit=False, + show_close=show_close, + close_text=close_text, + close_command=close_command, + theme_mode=theme_mode, + content_padding=content_padding, + show_banner=False, + defer_show=True, + modal=True, + center_on_screen=False, + **kwargs, + ) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py index b958dfab..3cb342e2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/processing_window.py @@ -5,9 +5,9 @@ import time import threading -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow -class ProcessingWindow(BHoMBaseWindow): +class ProcessingWindow(BHoMBaseChildWindow): """A simple processing window with animated indicator.""" def __init__(self, title="Processing", message="Processing...", *args, **kwargs): @@ -25,19 +25,18 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) theme_mode="auto", show_close=False, show_submit=False, + show_banner=False, + top_most=True, *args, **kwargs ) - - self.title(title) + self.attributes("-topmost", True) self.resizable(False, False) - # Container - container = ttk.Frame(self, padding=20) + container = ttk.Frame(self.content_frame, padding=20) container.pack(fill="both", expand=True) - # Message label (to calculate size) self.message_label = ttk.Label( container, text=message, @@ -53,7 +52,6 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) pass self.message_label.pack(pady=(0, 20)) - # Animation frame animation_frame = ttk.Frame(container) animation_frame.pack(expand=True) @@ -71,30 +69,12 @@ def __init__(self, title="Processing", message="Processing...", *args, **kwargs) pass self.animation_label.pack() - # Animation state self.animation_frames = ["●", "●", "●"] self.current_frame = 0 self.is_running = False - # Update to calculate the required size self.update_idletasks() - - # Get the required width and height - required_width = self.winfo_reqwidth() - required_height = self.winfo_reqheight() - - # Set minimum size - min_width = 300 - min_height = 150 - window_width = max(required_width, min_width) - window_height = max(required_height, min_height) - - # Center on screen - screen_width = self.winfo_screenwidth() - screen_height = self.winfo_screenheight() - x = (screen_width - window_width) // 2 - y = (screen_height - window_height) // 2 - self.geometry(f"{window_width}x{window_height}+{x}+{y}") + self.refresh_sizing() def start(self): @@ -103,7 +83,6 @@ def start(self): return self.is_running = True - # Run the Tk mainloop on the calling thread (must be main thread on many platforms). try: self._animate() self.mainloop() @@ -153,24 +132,11 @@ def keep_alive(self): def stop(self): """Stop the animation and close the window.""" self.is_running = False - try: - # Stop the mainloop if running and then destroy the window - if self.winfo_exists(): - try: - self.quit() - except Exception: - pass - try: - self.destroy() - except Exception: - pass - except Exception: - pass + self.destroy_root() def _animate(self): """Update animation frames.""" if self.is_running: - # Create rotating dot animation dots = ["◐", "◓", "◑", "◒"] self.animation_label.config(text=dots[self.current_frame % len(dots)]) self.current_frame += 1 @@ -180,15 +146,12 @@ def update_message(self, message: str): """Update the message text.""" try: self.message_label.config(text=message) - # schedule an idle update so the UI refreshes promptly self.update_idletasks() except Exception: pass if __name__ == "__main__": - # Test the processing window - processing = ProcessingWindow(title="Test Processing", message="Running Comfort and Safety Calculation...") def worker(): for i in range(50): diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py index c92e8f1a..1ae67fdd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/warning_box.py @@ -1,11 +1,11 @@ """Standardized warning dialog window for errors, warnings, and info messages.""" from python_toolkit.bhom_tkinter.widgets.label import Label -from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions -class WarningBox(BHoMBaseWindow): +class WarningBox(BHoMBaseChildWindow): """Show categorized messages using the shared BHoM window styling.""" def __init__( diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index 5586c8dd..932a18fd 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -4,6 +4,7 @@ from pathlib import Path import matplotlib.pyplot as plt +import tkinter as tk from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow from python_toolkit.bhom_tkinter.widgets import ( @@ -28,6 +29,8 @@ ProcessingWindow, WarningBox, ) +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from python_toolkit.bhom_tkinter.windows.modal_window import BHoMModalWindow def _demo_callback(*_args, **_kwargs): @@ -316,3 +319,32 @@ def test_rebuild(): root.destroy_root() + +def test_modal_window_is_themed_toplevel(): + """Modal windows should be themed Toplevels, not tk.Tk roots.""" + host = BHoMBaseWindow(title="Modal host", show_submit=False, show_close=False) + host.withdraw() + + modal = BHoMModalWindow( + host, + title="Modal test", + width=420, + height=260, + show_close=True, + ) + assert isinstance(modal, tk.Toplevel) + assert isinstance(modal, BHoMBaseChildWindow) + assert hasattr(modal, "content_frame") + assert not isinstance(modal, BHoMBaseWindow) + + Label( + modal.content_frame, + text="Modal body", + build_options=PackingOptions(anchor="w"), + ).build() + modal.update_idletasks() + assert modal.winfo_exists() + + modal.close() + host.destroy_root() + From abe3501a9de9dbd2a06c5401f1791f0f649369d0 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 21 Aug 2026 09:57:57 +0100 Subject: [PATCH 18/50] trimmed some fat from the styling sizing --- .../bhom_tkinter/bhom_window_shell.py | 12 +- .../bhom_tkinter/theming/bhom_dark_theme.tcl | 150 +++++++++--------- .../bhom_tkinter/theming/bhom_light_theme.tcl | 150 +++++++++--------- 3 files changed, 156 insertions(+), 156 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py index 0b922bbf..2f57ce6d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py @@ -236,16 +236,16 @@ def _load_theme(self) -> str: def _ensure_typography_styles(self, style: ttk.Style) -> None: """Ensure key typography styles exist and remain visually distinct.""" defaults = { - "TLabel": ("Segoe UI", 10, "bold"), + "TLabel": ("Segoe UI", 10), "Body.TLabel": ("Segoe UI", 10), "Caption.TLabel": ("Segoe UI", 9), "Small.TLabel": ("Segoe UI", 8), "Heading.TLabel": ("Segoe UI", 12, "bold"), - "Subtitle.TLabel": ("Segoe UI", 14, "bold"), - "Headline.TLabel": ("Segoe UI", 16, "bold"), - "Title.TLabel": ("Segoe UI", 24, "bold"), - "LargeTitle.TLabel": ("Segoe UI", 24, "bold"), - "Display.TLabel": ("Segoe UI", 28, "bold"), + "Subtitle.TLabel": ("Segoe UI", 12, "bold"), + "Headline.TLabel": ("Segoe UI", 14, "bold"), + "Title.TLabel": ("Segoe UI", 18, "bold"), + "LargeTitle.TLabel": ("Segoe UI", 18, "bold"), + "Display.TLabel": ("Segoe UI", 20, "bold"), } def _lookup_font(style_name: str) -> str: diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl index acedbf7b..cf3798fe 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl @@ -63,7 +63,7 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure Card.TFrame \ -background $colors(-dark) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ -bordercolor $colors(-border-light) @@ -71,30 +71,30 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TLabel \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} + -font {{Segoe UI} 10} ttk::style configure Display.TLabel \ - -font {{Segoe UI} 28 bold} \ + -font {{Segoe UI} 20 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure LargeTitle.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Title.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Headline.TLabel \ - -font {{Segoe UI} 16 bold} \ + -font {{Segoe UI} 14 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure Subtitle.TLabel \ - -font {{Segoe UI} 14 bold} \ + -font {{Segoe UI} 12 bold} \ -foreground $colors(-fg) \ -padding {0 0} @@ -140,22 +140,22 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -focuscolor "" \ - -padding {16 8} \ - -relief raised + -padding {12 6} \ + -relief flat # Large Button variant ttk::style configure Large.TButton \ -font {{Segoe UI} 12 bold} \ - -padding {20 12} \ - -borderwidth 2 + -padding {14 8} \ + -borderwidth 1 # Small Button variant ttk::style configure Small.TButton \ -font {{Segoe UI} 8 bold} \ - -padding {12 6} \ - -borderwidth 2 + -padding {8 4} \ + -borderwidth 1 ttk::style map TButton \ -background [list \ @@ -185,9 +185,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-primary-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Primary.TButton \ -background [list \ @@ -211,9 +211,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-secondary) \ -lightcolor $colors(-secondary) \ -darkcolor $colors(-secondary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Secondary.TButton \ -background [list \ @@ -231,9 +231,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-tertiary) \ -lightcolor $colors(-tertiary) \ -darkcolor "#9fad00" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Accent.TButton \ -background [list \ @@ -253,9 +253,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-success) \ -lightcolor $colors(-success) \ -darkcolor "#1e9038" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Success.TButton \ -background [list \ @@ -271,7 +271,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ - -padding {14 8} \ + -padding {10 4} \ -relief flat ttk::style map Link.TButton \ @@ -288,9 +288,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-primary) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Outline.TButton \ -background [list \ @@ -308,7 +308,7 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ - -padding {14 8} + -padding {10 4} ttk::style map Text.TButton \ -background [list \ @@ -325,8 +325,8 @@ namespace eval ttk::theme::bhom_dark { -lightcolor $colors(-border) \ -darkcolor $colors(-hover-bg) \ -insertcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TEntry \ @@ -346,8 +346,8 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TCombobox \ @@ -365,11 +365,11 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TCheckbutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -394,11 +394,11 @@ namespace eval ttk::theme::bhom_dark { } ttk::style configure Checkbox.TCheckbutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorrelief flat \ - -indicatorsize 18 \ + -indicatorsize 16 \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -423,11 +423,11 @@ namespace eval ttk::theme::bhom_dark { ttk::style configure TRadiobutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -453,9 +453,9 @@ namespace eval ttk::theme::bhom_dark { } ttk::style configure Radio.TRadiobutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorsize 15 \ -borderwidth 0 \ -relief flat \ @@ -497,7 +497,7 @@ namespace eval ttk::theme::bhom_dark { -arrowsize 0 \ -borderwidth 0 \ -relief flat \ - -width 10 + -width 8 ttk::style map TScrollbar \ -background [list \ @@ -510,7 +510,7 @@ namespace eval ttk::theme::bhom_dark { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Vertical.TScrollbar \ -background [list \ @@ -523,7 +523,7 @@ namespace eval ttk::theme::bhom_dark { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Horizontal.TScrollbar \ -background [list \ @@ -573,24 +573,24 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -thickness 24 \ - -relief raised + -borderwidth 1 \ + -thickness 16 \ + -relief flat # Notebook - soft rounded tabs ttk::style configure TNotebook \ -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -tabmargins {2 5 2 0} \ - -borderwidth 2 + -borderwidth 1 ttk::style configure TNotebook.Tab \ -background $colors(-dark) \ -foreground $colors(-text-secondary) \ -bordercolor $colors(-border-light) \ - -font {{Segoe UI} 11 bold} \ - -padding {18 10} \ - -borderwidth 2 + -font {{Segoe UI} 10 bold} \ + -padding {12 6} \ + -borderwidth 1 ttk::style map TNotebook.Tab \ -background [list \ @@ -610,9 +610,9 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-border-light) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -rowheight 32 \ - -padding {6 4} + -borderwidth 1 \ + -rowheight 26 \ + -padding {4 2} ttk::style map Treeview \ -background [list selected $colors(-primary)] \ @@ -622,9 +622,9 @@ namespace eval ttk::theme::bhom_dark { -background $colors(-dark) \ -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ - -relief raised \ - -padding {10 8} \ - -font {{Segoe UI} 11 bold} + -relief flat \ + -padding {8 6} \ + -font {{Segoe UI} 10 bold} ttk::style map Treeview.Heading \ -background [list active $colors(-hover-bg)] \ @@ -647,22 +647,22 @@ namespace eval ttk::theme::bhom_dark { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ - -padding {16 12} + -padding {10 8} ttk::style configure TLabelframe.Label \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 12 bold} \ - -padding {10 -8} + -font {{Segoe UI} 10 bold} \ + -padding {8 -6} # Panedwindow ttk::style configure TPanedwindow \ -background $colors(-bg) ttk::style configure Sash \ - -sashthickness 8 \ + -sashthickness 6 \ -gripcount 0 \ -background $colors(-border) @@ -676,8 +676,8 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TSpinbox \ @@ -695,9 +695,9 @@ namespace eval ttk::theme::bhom_dark { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {14 8} \ - -borderwidth 2 \ - -relief raised + -padding {12 6} \ + -borderwidth 1 \ + -relief flat ttk::style map TMenubutton \ -background [list \ @@ -718,7 +718,7 @@ namespace eval ttk::theme::bhom_dark { # Set default options for tk widgets (non-ttk) option add *Background "#1e1e1e" option add *Foreground "#ffffff" -option add *Font {{Segoe UI} 10 bold} +option add *Font {{Segoe UI} 10} option add *selectBackground "#1b6ec2" option add *selectForeground "#ffffff" option add *activeBackground "#2a2d2e" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl index c6973f46..8b52cc3b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl @@ -63,7 +63,7 @@ namespace eval ttk::theme::bhom_light { ttk::style configure Card.TFrame \ -background $colors(-dark) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ -bordercolor $colors(-border-light) @@ -71,30 +71,30 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TLabel \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} + -font {{Segoe UI} 10} ttk::style configure Display.TLabel \ - -font {{Segoe UI} 28 bold} \ + -font {{Segoe UI} 20 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure LargeTitle.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Title.TLabel \ - -font {{Segoe UI} 24 bold} \ + -font {{Segoe UI} 18 bold} \ -foreground $colors(-fg) \ -padding {0 0} ttk::style configure Headline.TLabel \ - -font {{Segoe UI} 16 bold} \ + -font {{Segoe UI} 14 bold} \ -foreground $colors(-primary) \ -padding {0 0} ttk::style configure Subtitle.TLabel \ - -font {{Segoe UI} 14 bold} \ + -font {{Segoe UI} 12 bold} \ -foreground $colors(-fg) \ -padding {0 0} @@ -140,22 +140,22 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -focuscolor "" \ - -padding {16 8} \ - -relief raised + -padding {12 6} \ + -relief flat # Large Button variant ttk::style configure Large.TButton \ -font {{Segoe UI} 12 bold} \ - -padding {20 12} \ - -borderwidth 2 + -padding {14 8} \ + -borderwidth 1 # Small Button variant ttk::style configure Small.TButton \ -font {{Segoe UI} 8 bold} \ - -padding {12 6} \ - -borderwidth 2 + -padding {8 4} \ + -borderwidth 1 ttk::style map TButton \ -background [list \ @@ -185,9 +185,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-primary-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Primary.TButton \ -background [list \ @@ -214,9 +214,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-secondary) \ -lightcolor $colors(-secondary) \ -darkcolor $colors(-secondary-hover) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Secondary.TButton \ -background [list \ @@ -234,9 +234,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-tertiary) \ -lightcolor $colors(-tertiary) \ -darkcolor "#8a8a00" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Accent.TButton \ -background [list \ @@ -256,9 +256,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-success) \ -lightcolor $colors(-success) \ -darkcolor "#1e9038" \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Success.TButton \ -background [list \ @@ -274,7 +274,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-info) \ -borderwidth 0 \ - -padding {14 8} \ + -padding {10 4} \ -relief flat ttk::style map Link.TButton \ @@ -291,9 +291,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-primary) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -padding {16 8} \ - -relief raised + -borderwidth 1 \ + -padding {12 6} \ + -relief flat ttk::style map Outline.TButton \ -background [list \ @@ -311,7 +311,7 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -foreground $colors(-primary) \ -borderwidth 0 \ - -padding {14 8} + -padding {10 4} ttk::style map Text.TButton \ -background [list \ @@ -328,8 +328,8 @@ namespace eval ttk::theme::bhom_light { -lightcolor $colors(-border) \ -darkcolor $colors(-hover-bg) \ -insertcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TEntry \ @@ -349,8 +349,8 @@ namespace eval ttk::theme::bhom_light { -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TCombobox \ @@ -368,11 +368,11 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TCheckbutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -397,11 +397,11 @@ namespace eval ttk::theme::bhom_light { } ttk::style configure Checkbox.TCheckbutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorrelief flat \ - -indicatorsize 18 \ + -indicatorsize 16 \ -borderwidth 0 \ -relief flat \ -focusthickness 0 \ @@ -426,11 +426,11 @@ namespace eval ttk::theme::bhom_light { ttk::style configure TRadiobutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 10 bold} \ - -padding {10 6} \ + -font {{Segoe UI} 10} \ + -padding {6 4} \ -indicatorcolor $colors(-inputbg) \ -indicatorbackground $colors(-inputbg) \ - -indicatormargin {0 0 10 0} \ + -indicatormargin {0 0 6 0} \ -borderwidth 0 \ -relief flat @@ -456,9 +456,9 @@ namespace eval ttk::theme::bhom_light { } ttk::style configure Radio.TRadiobutton \ - -font {{Segoe UI} 11} \ - -padding {6 8} \ - -indicatormargin {0 0 10 0} \ + -font {{Segoe UI} 10} \ + -padding {4 4} \ + -indicatormargin {0 0 6 0} \ -indicatorsize 15 \ -borderwidth 0 \ -relief flat \ @@ -500,7 +500,7 @@ namespace eval ttk::theme::bhom_light { -arrowsize 0 \ -borderwidth 0 \ -relief flat \ - -width 10 + -width 8 ttk::style map TScrollbar \ -background [list \ @@ -513,7 +513,7 @@ namespace eval ttk::theme::bhom_light { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Vertical.TScrollbar \ -background [list \ @@ -526,7 +526,7 @@ namespace eval ttk::theme::bhom_light { -troughcolor $colors(-bg) \ -arrowsize 0 \ -borderwidth 0 \ - -width 10 + -width 8 ttk::style map Horizontal.TScrollbar \ -background [list \ @@ -576,24 +576,24 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-primary-light) \ -darkcolor $colors(-primary-hover) \ - -borderwidth 2 \ - -thickness 24 \ - -relief raised + -borderwidth 1 \ + -thickness 16 \ + -relief flat # Notebook - soft rounded tabs ttk::style configure TNotebook \ -background $colors(-bg) \ -bordercolor $colors(-border-light) \ -tabmargins {2 5 2 0} \ - -borderwidth 2 + -borderwidth 1 ttk::style configure TNotebook.Tab \ -background $colors(-dark) \ -foreground $colors(-text-secondary) \ -bordercolor $colors(-border-light) \ - -font {{Segoe UI} 11 bold} \ - -padding {18 10} \ - -borderwidth 2 + -font {{Segoe UI} 10 bold} \ + -padding {12 6} \ + -borderwidth 1 ttk::style map TNotebook.Tab \ -background [list \ @@ -613,9 +613,9 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-border-light) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ - -rowheight 32 \ - -padding {6 4} + -borderwidth 1 \ + -rowheight 26 \ + -padding {4 2} ttk::style map Treeview \ -background [list selected $colors(-primary)] \ @@ -625,9 +625,9 @@ namespace eval ttk::theme::bhom_light { -background $colors(-dark) \ -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ - -relief raised \ - -padding {10 8} \ - -font {{Segoe UI} 11 bold} + -relief flat \ + -padding {8 6} \ + -font {{Segoe UI} 10 bold} ttk::style map Treeview.Heading \ -background [list active $colors(-hover-bg)] \ @@ -650,22 +650,22 @@ namespace eval ttk::theme::bhom_light { -bordercolor $colors(-border-light) \ -lightcolor $colors(-hover-bg) \ -darkcolor $colors(-border) \ - -borderwidth 2 \ + -borderwidth 1 \ -relief groove \ - -padding {16 12} + -padding {10 8} ttk::style configure TLabelframe.Label \ -background $colors(-bg) \ -foreground $colors(-fg) \ - -font {{Segoe UI} 12 bold} \ - -padding {10 -8} + -font {{Segoe UI} 10 bold} \ + -padding {8 -6} # Panedwindow ttk::style configure TPanedwindow \ -background $colors(-bg) ttk::style configure Sash \ - -sashthickness 8 \ + -sashthickness 6 \ -gripcount 0 \ -background $colors(-border) @@ -679,8 +679,8 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-inputfg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {10 8} \ - -borderwidth 2 \ + -padding {8 5} \ + -borderwidth 1 \ -relief sunken ttk::style map TSpinbox \ @@ -698,9 +698,9 @@ namespace eval ttk::theme::bhom_light { -foreground $colors(-fg) \ -bordercolor $colors(-border-light) \ -arrowcolor $colors(-fg) \ - -padding {14 8} \ - -borderwidth 2 \ - -relief raised + -padding {12 6} \ + -borderwidth 1 \ + -relief flat ttk::style map TMenubutton \ -background [list \ @@ -717,7 +717,7 @@ namespace eval ttk::theme::bhom_light { # Set default options for tk widgets (non-ttk) option add *Background "#ffffff" option add *Foreground "#1a1a1a" -option add *Font {{Segoe UI} 10 bold} +option add *Font {{Segoe UI} 10} option add *selectBackground "#1b6ec2" option add *selectForeground "#ffffff" option add *activeBackground "#e0e0e0" From d12260f7e3758afe24a5653c715980a770382b43 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 21 Aug 2026 11:48:11 +0100 Subject: [PATCH 19/50] added binning to heatmap to ensure bounds include full series --- .../Python/src/python_toolkit/plot/heatmap.py | 17 +++++++++-- Python_Engine/Python/tests/test_plot.py | 29 ++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py index 845fad84..2eb7e280 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py +++ b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py @@ -9,6 +9,15 @@ from ..helpers.timeseries import validate_timeseries +def _bin_edges(coords, default_width: float): + """Edges so each centre keeps a flat pcolormesh cell.""" + coords = np.asarray(coords, dtype=float) + if coords.size == 0: + return coords + width = default_width if coords.size == 1 else np.diff(coords)[-1] + return np.concatenate([coords, coords[-1:] + width]) + + @bhom_analytics() def heatmap( series: pd.Series, @@ -71,10 +80,12 @@ def heatmap( if ax is None: ax = plt.gca() + kwargs.pop("shading", None) pcm = ax.pcolormesh( - x, - y, - z[:-1, :-1], + _bin_edges(x, 1.0), + _bin_edges(y, 1.0 / 24.0), + z, + shading="flat", **kwargs, ) diff --git a/Python_Engine/Python/tests/test_plot.py b/Python_Engine/Python/tests/test_plot.py index 3544bc6c..bf94258b 100644 --- a/Python_Engine/Python/tests/test_plot.py +++ b/Python_Engine/Python/tests/test_plot.py @@ -160,4 +160,31 @@ def test_heatmap(): ), plt.Axes, ) - plt.close("all") \ No newline at end of file + plt.close("all") + + +def _heatmap_cell_count(series: pd.Series) -> int: + ax = heatmap(series) + count = np.ma.getdata(ax.collections[0].get_array()).size + plt.close("all") + return count + + +def test_heatmap_keeps_last_bins(): + hourly_two_days = pd.Series( + np.arange(48, dtype=float), + index=pd.date_range("2000-01-01", periods=48, freq="h"), + ) + assert _heatmap_cell_count(hourly_two_days) == 48 + + half_hourly_day = pd.Series( + np.arange(48, dtype=float), + index=pd.date_range("2000-01-01", periods=48, freq="30min"), + ) + assert _heatmap_cell_count(half_hourly_day) == 48 + + hourly_one_day = pd.Series( + np.arange(24, dtype=float), + index=pd.date_range("2000-01-01", periods=24, freq="h"), + ) + assert _heatmap_cell_count(hourly_one_day) == 24 \ No newline at end of file From 6b24873573d4f3ca690f3fa7aa6bdb78fabfb9c1 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Sun, 23 Aug 2026 20:56:10 +0100 Subject: [PATCH 20/50] ci: onboard new CI checks (beta tier) --- .github/workflows/ci-beta.yml | 138 ++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .github/workflows/ci-beta.yml diff --git a/.github/workflows/ci-beta.yml b/.github/workflows/ci-beta.yml new file mode 100644 index 00000000..b7ade026 --- /dev/null +++ b/.github/workflows/ci-beta.yml @@ -0,0 +1,138 @@ +# Beta-tier proxy, BHoM variant. Bundles the tier's checks plus +# copyright-compliance (BHoM enforces OSS copyright; the BHE variant omits it). +# Copy to .github/workflows/ci-beta.yml in each BHoM beta repo. +# Runs on pull_request, non-blocking until a ruleset requires its checks. +# Workflow name CI keeps check contexts bare: required contexts are the job names. + +name: CI + +on: + pull_request: + branches: + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +jobs: + ci-build: + runs-on: windows-2025-vs2026 + timeout-minutes: 30 + steps: + - name: Run build + uses: BHoM/CI_Toolkit/.github/actions/ci-build@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-code-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run code compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: code + patterns: '*.cs' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-copyright-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run copyright compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: copyright + patterns: '*.cs' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-dataset-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run dataset compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: dataset + patterns: ':(icase)*datasets*.json' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-documentation-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run documentation compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: documentation + patterns: '*.cs' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-project-compliance: + runs-on: windows-2025-vs2026 + timeout-minutes: 20 + permissions: + contents: read + pull-requests: read + actions: write + steps: + - name: Run project compliance + uses: BHoM/CI_Toolkit/.github/actions/ci-compliance@develop + with: + check_type: project + patterns: '*AssemblyInfo.cs *.csproj' + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-dataset-tests: + runs-on: windows-2025-vs2026 + timeout-minutes: 30 + steps: + - name: Run dataset tests + uses: BHoM/CI_Toolkit/.github/actions/ci-dataset-tests@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + + ci-serialisation: + runs-on: windows-2025-vs2026 + timeout-minutes: 90 + steps: + - name: Run serialisation + uses: BHoM/CI_Toolkit/.github/actions/ci-serialisation@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} + base_ref: ${{ github.base_ref }} + + ci-versioning: + runs-on: windows-2025-vs2026 + timeout-minutes: 90 + steps: + - name: Run versioning + uses: BHoM/CI_Toolkit/.github/actions/ci-versioning@develop + with: + app_id: ${{ secrets.BHOM_APP_ID }} + private_key: ${{ secrets.BHOM_APP_PRIVATE_KEY }} From b5bb0ca9e188f96abbac002da1bff78fd497d30f Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 26 Aug 2026 11:02:17 +0100 Subject: [PATCH 21/50] added python version thank god for pythonnet --- .../Python/src/python_toolkit/bhom/__init__.py | 5 ++++- .../src/python_toolkit/bhom/installer_info.py | 15 +++++++++++++++ .../bhom_tkinter/bhom_window_shell.py | 4 +++- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/installer_info.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index 4933adf6..8519de94 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -6,10 +6,12 @@ import tempfile import importlib.metadata +from .installer_info import INSTALLER_INFO + BHOM_LOG_FOLDER = Path(path.expandvars("%PROGRAMDATA%/BHoM/Logs")) TEMP_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" TOOLKIT_NAME = "Python_Toolkit" -BHOM_VERSION = importlib.metadata.version("python_toolkit") +BHOM_VERSION = INSTALLER_INFO["Version"] #Environment variable that if set disables BHoM analytics logging. DISABLE_ANALYTICS = os.environ.get("DISABLE_BHOM_ANALYTICS", None) @@ -33,3 +35,4 @@ BHOM_LOG_FOLDER.mkdir(exist_ok=True, parents=True) + diff --git a/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py b/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py new file mode 100644 index 00000000..21ce80eb --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py @@ -0,0 +1,15 @@ +import clr +import sys +import json + +#append assemblies dir +sys.path.append("C:\\ProgramData\\BHoM\\Assemblies") + +#add reference to the BHoM_UI assembly +clr.AddReference("UI_Engine") +clr.AddReference("Serialiser_Engine") + +from BH.Engine.UI import Query +from BH.Engine.Serialiser import Convert + +INSTALLER_INFO = json.loads(Convert.ToJson(Query.Information())) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py index 0b922bbf..25c6d3a5 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_window_shell.py @@ -12,6 +12,8 @@ from python_toolkit.bhom_tkinter.widgets.button import Button from python_toolkit.bhom_tkinter.theming.theme import ThemeManager +from python_toolkit.bhom import BHOM_VERSION + class BHoMWindowShell: """Shared themed window chrome for BHoM Tk root and Toplevel windows.""" @@ -323,7 +325,7 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] # Subtitle subtitle_label = Label( text_container, - text="powered by BHoM", + text=f"powered by BHoM (v{BHOM_VERSION})", style="Caption.TLabel" ) subtitle_label.pack(anchor="w") From c244b7c82e7e1afac94fc802ff215a6ca8987752 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 26 Aug 2026 13:59:54 +0100 Subject: [PATCH 22/50] add pythonnet as a dependency and make sure that c# is only called in windows --- Python_Engine/Python/pyproject.toml | 1 + Python_Engine/Python/src/python_toolkit/bhom/__init__.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Python_Engine/Python/pyproject.toml b/Python_Engine/Python/pyproject.toml index 6a0be4e7..6db0bd6c 100644 --- a/Python_Engine/Python/pyproject.toml +++ b/Python_Engine/Python/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pytest-cov>=6.0.0", "pytest-order", "virtualenv", + "pythonnet", ] [urls] diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index 8519de94..e3ab0b23 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -6,7 +6,10 @@ import tempfile import importlib.metadata -from .installer_info import INSTALLER_INFO +if os.name == 'nt': + from .installer_info import INSTALLER_INFO +else: + INSTALLER_INFO = {"Version": importlib.metadata.version("python_toolkit")} BHOM_LOG_FOLDER = Path(path.expandvars("%PROGRAMDATA%/BHoM/Logs")) TEMP_LOG_FOLDER = Path(tempfile.gettempdir()) / "BHoM" / "Logs" From 873e88eb0667959373078c6addb2b851412792d1 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 26 Aug 2026 15:28:08 +0100 Subject: [PATCH 23/50] make bhom path directory agnostic --- .../Python/src/python_toolkit/bhom/installer_info.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py b/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py index 21ce80eb..10f1b76d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/installer_info.py @@ -1,11 +1,13 @@ -import clr +import os import sys import json +import clr + #append assemblies dir -sys.path.append("C:\\ProgramData\\BHoM\\Assemblies") +sys.path.append(os.path.expandvars("%ProgramData%\\BHoM\\Assemblies")) -#add reference to the BHoM_UI assembly +#add required CLR references (dotnet dlls) clr.AddReference("UI_Engine") clr.AddReference("Serialiser_Engine") From d566b5f73950c7e8197b48382bd0de3d52003615 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 21 May 2026 15:27:17 +0100 Subject: [PATCH 24/50] start with a simple copy of BH.oM.BHoMObject with some simple helper properties also start JSON encoder and decoder but unsure of what to do here for decoder. --- .../src/python_toolkit/bhom/bhom_object.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py new file mode 100644 index 00000000..62a8af46 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -0,0 +1,36 @@ +import uuid +from typing import List, Dict +from json import JSONEEncoder, JSONDecoder +from .. import TOOLKIT_NAME + + +class BHoMJSONDecoder(JSONDecoder): + +class BHoMJSONEncoder(JSONEncoder): + def default(self, o): + return o.__dict__ + + +class BHoMObject: + def __init__( + self, + name: str = "", + bhom_guid: uuid.UUID = uuid.uuid4(), + tags: List[str] = [], + fragments: Dict[type, object] = {}, + custom_data: Dict[str, object] = {} + ) -> BHoMObject: + + self.name = name + self.bhom_guid = bhom_guid + self.fragments = fragments + self.tags = tags + self.custom_data = custom_data + + @property + def namespace(self): + return "BH.oM.Python" + + @property + def _t(self): + return namespace + "." + type(self).__name__ \ No newline at end of file From 2c32f57ce857ef97f1392bc2f18d501b5bc9c1db Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 26 May 2026 15:40:53 +0100 Subject: [PATCH 25/50] further develop BHoMObject class and helper methods, and add some tests --- .../src/python_toolkit/bhom/bhom_object.py | 104 ++++++++++++++++-- .../Python/tests/test_bhom_serialiser.py | 34 ++++++ 2 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 Python_Engine/Python/tests/test_bhom_serialiser.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 62a8af46..908fb5b2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -1,24 +1,98 @@ import uuid +import re from typing import List, Dict +import json from json import JSONEEncoder, JSONDecoder from .. import TOOLKIT_NAME +def convert_pascal_to_camel(s: str): + """Converts a string to camel_case.""" + sections = re.split("(?<=.)(?=[A-Z])", s) #zero-length match before capitals, skipping capital at the 0th index + parts = [] + for sec in sections: + parts.append(sec.lower()) + + return parts.join("_") + +def convert_camel_to_pascal(s: str): + """Converts a string to PascalCase, ignoring _ if it is the first character.""" + sections = re.split(r'(?<=.)_', s) #match all `_` except if it is the first character. + + parts = [] + for sec in sections: #capitalise each section unless the section is empty (in which case, append an underscore) or the section starts with an underscore (first section can begin with _) + if sec == "": + parts.append("_") + continue + elif sec.startswith("_"): + parts.append(sec) + + parts.append(sec.capitalize()) + + return ''.join(parts) class BHoMJSONDecoder(JSONDecoder): - + def __init__(self, *args, **kwargs): + super().__init__(self, object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, d): + if "_t" not in d: + return d + + #get default BHoMObject properties and replace with defaults if not present + name = d.pop("Name", "") + bhom_guid = d.pop("BHoM_Guid", uuid.uuid4()) + tags = d.pop("Tags", []) + fragments = d.pop("Fragments", {}) + custom_data = d.pop("CustomData", {}) + _t = d.pop("_t") + + #convert all properties to camel_case as python users expect + props = {} + for prop_name in d: + props[convert_pascal_to_camel(prop_name)] = d[prop_name] + + return BHoMObject(name, bhom_guid, tags, fragments, custom_data, _t, **props) + class BHoMJSONEncoder(JSONEncoder): def default(self, o): - return o.__dict__ - + if isinstance(o, BHoMObject): + #initialise special BHoMObject properties + props = { + "Name": o.name, + "BHoM_Guid": o.bhom_guid, + "Tags": o.tags, + "Fragments": o.fragments, + "CustomData": o.custom_data, + "_t": o._t + } + + #get property names with reflection and convert all properties to PascalCase as the BHoM serialiser expects + for prop_name, value in vars(o).items(): + if prop_name in ["name", "bhom_guid", "tags", "fragments", "custom_data", "_t"] + continue + props[convert_camel_to_pascal(prop_name)] = value + return props + + return super(BHoMJSONDecoder, self).default(o) #fallback to default json decoder if object is not a BHoMObject (don't convert property case). + class BHoMObject: + name: str + bhom_guid: uuid.UUID + tags: List[str] + fragments: Dict[type, object] + custom_data: Dict[str, object] + _t: str + def __init__( self, name: str = "", bhom_guid: uuid.UUID = uuid.uuid4(), tags: List[str] = [], fragments: Dict[type, object] = {}, - custom_data: Dict[str, object] = {} + custom_data: Dict[str, object] = {}, + _t: str = "BH.oM.Base.BHoMObject" + **kwargs ) -> BHoMObject: self.name = name @@ -26,11 +100,19 @@ def __init__( self.fragments = fragments self.tags = tags self.custom_data = custom_data + self._t = _t - @property - def namespace(self): - return "BH.oM.Python" - - @property - def _t(self): - return namespace + "." + type(self).__name__ \ No newline at end of file + #set non-CustomData properties with reflection. + for kwarg in kwargs: + setattr(self, kwarg, kwargs[kwarg]) + + @classmethod + def from_json(j: str) -> 'BHoMObject': + obj = json.loads(json, decoder=BHoMJSONDecoder) + if not isinstance(obj, BHoMObject): + raise TypeError("The object provided does not deserialise to a valid BHoM object.") + return obj + + def to_json(self) -> str: + s = json.dumps(self, encoder=BHoMJSONEncoder) + return s \ No newline at end of file diff --git a/Python_Engine/Python/tests/test_bhom_serialiser.py b/Python_Engine/Python/tests/test_bhom_serialiser.py new file mode 100644 index 00000000..92caecc5 --- /dev/null +++ b/Python_Engine/Python/tests/test_bhom_serialiser.py @@ -0,0 +1,34 @@ + +SERIALISED_BHOM_OBJECT = '{}' + +from python_toolkit.bhom.bhom_object import convert_pascal_to_camel, convert_camel_to_pascal, BHoMObject, BHoMJSONDecoder, BHoMJSONEncoder + +def test_case_convert(): + """Test that the camel and pascal converters are working correctly by using expected outputs and a round trip both ways.""" + + #arrange + test_pascal_str = "ThisIsAPascalCaseString" + test_camel_str = "this_is_a_camel_case_string" + expected_pascal_out = "this_is_a_pascal_case_string" + expected_camel_out = "ThisIsACamelCaseString" + + #act + pascal_out = convert_pascal_to_camel(test_pascal_str) + pascal_round_trip = convert_camel_to_pascal(pascal_out) + + camel_out = convert_camel_to_pascal(test_camel_str) + camel_round_trip = convert_pascal_to_camel(camel_out) + + #assert + assert pascal_out == expected_pascal_out, f"pascal conversion got '{pascal_out}' but expected '{expected_pascal_out}'." + assert camel_out == expected_camel_out, f"camel conversion got '{camel_out}' but expected '{expected_camel_out}'." + assert pascal_round_trip == test_pascal_str, f"pascal round trip got '{pascal_round_trip}' but expected '{test_pascal_str}'." + assert camel_round_trip == test_camel_str, f"camel round trip got '{camel_round_trip}' but expected '{test_camel_str}'." + +def test_serialise_bhom_object() + """Test that bhom objects serialise correctly to a format that the c# bhom serialiser accepts as valid, and with the correct property case.""" + assert False #fail for now as test is not done + +def test_deserialise_bhom_object() + """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" + assert False #fail for now as test is not done \ No newline at end of file From fb4cc5d96f30c5f205c3c1b7e006a53a8cb31287 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 26 May 2026 16:26:43 +0100 Subject: [PATCH 26/50] refine tests a little and add debug and slightly improve an error message --- .../src/python_toolkit/bhom/bhom_object.py | 7 ++++-- .../Python/tests/test_bhom_serialiser.py | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 908fb5b2..648e344b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -3,7 +3,7 @@ from typing import List, Dict import json from json import JSONEEncoder, JSONDecoder -from .. import TOOLKIT_NAME +from .logging import CONSOLE_LOGGER def convert_pascal_to_camel(s: str): """Converts a string to camel_case.""" @@ -36,6 +36,7 @@ def __init__(self, *args, **kwargs): def object_hook(self, d): if "_t" not in d: + CONSOLE_LOGGER.debug(f"BHoMJSONDecoder could not convert the following dictionary into a BHoMObject due to a missing '_t' property. Falling back to dictionary: {d}") return d #get default BHoMObject properties and replace with defaults if not present @@ -109,8 +110,10 @@ def __init__( @classmethod def from_json(j: str) -> 'BHoMObject': obj = json.loads(json, decoder=BHoMJSONDecoder) - if not isinstance(obj, BHoMObject): + + if not isinstance(obj, BHoMObject): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. raise TypeError("The object provided does not deserialise to a valid BHoM object.") + return obj def to_json(self) -> str: diff --git a/Python_Engine/Python/tests/test_bhom_serialiser.py b/Python_Engine/Python/tests/test_bhom_serialiser.py index 92caecc5..92e6b62b 100644 --- a/Python_Engine/Python/tests/test_bhom_serialiser.py +++ b/Python_Engine/Python/tests/test_bhom_serialiser.py @@ -1,7 +1,8 @@ - -SERIALISED_BHOM_OBJECT = '{}' - from python_toolkit.bhom.bhom_object import convert_pascal_to_camel, convert_camel_to_pascal, BHoMObject, BHoMJSONDecoder, BHoMJSONEncoder +import json + +SERIALISED_BHOM_OBJECT = '{"_t": "BH.oM.Base.BHoMObject", "Name": "test_object"}' #TODO: use the BHoM serialiser to make a BHoMObject json string, and use that here. +DESERIALISED_BHOM_OBJECT = BHoMObject(name = "test_object") #TODO: make equivalent BHoMObject here identical to the one above. def test_case_convert(): """Test that the camel and pascal converters are working correctly by using expected outputs and a round trip both ways.""" @@ -27,8 +28,20 @@ def test_case_convert(): def test_serialise_bhom_object() """Test that bhom objects serialise correctly to a format that the c# bhom serialiser accepts as valid, and with the correct property case.""" - assert False #fail for now as test is not done + + serialised = json.dumps(DESERIALISED_BHOM_OBJECT, encoder=BHoMJSONEncoder) + serialised_bhom_object_to_json = DESERIALISED_BHOM_OBJECT.to_json() + round_trip = BHoMObject.from_json(serialised) + + assert serialised == serialised_bhom_object_to_json, f"Direct serialisation to json differed to BHoMObject to_json method." + assert round_trip == DESERIALISED_BHOM_OBJECT, f"BHoMObject round trip failed for serialisation -> deserialisation." #this is the only direction the round trip can be tested without directly inspecting each dictionary entry, as it is not guaranteed that the other direction will produce identical order for json strings. def test_deserialise_bhom_object() """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" - assert False #fail for now as test is not done \ No newline at end of file + + obj = json.loads(SERIALISED_BHOM_OBJECT, decoder=BHoMJSONDecoder) + obj_bhom_object_from_json = BHoMObject.from_json(SERIALISED_BHOM_OBJECT) + + assert isinstance(obj, BHoMObject), "JSON decoded an object of the wrong type" + assert obj == DESERIALISED_BHOM_OBJECT, f"Actual deserialised object ({obj}) was not identical to expected deserialised object ({DESERIALISED_BHOM_OBJECT})." + assert obj_bhom_object_from_json == obj, f"Direct deserialisation from json differed to BHoMObject from_json method." \ No newline at end of file From f448e2f20279d5e0925336469c9ffca55c72ed42 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 28 May 2026 11:13:02 +0100 Subject: [PATCH 27/50] added extra class to handle non bhom objects serialised by the bhom serialiser, and improved from_json class method to use sub class initialiser to return the correct type when BHoMObject is sub classed. --- .../src/python_toolkit/bhom/bhom_object.py | 307 ++++++++++++------ .../Python/tests/test_bhom_serialiser.py | 37 ++- 2 files changed, 239 insertions(+), 105 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 648e344b..0dc10b4f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -2,17 +2,17 @@ import re from typing import List, Dict import json -from json import JSONEEncoder, JSONDecoder +from json import JSONEncoder, JSONDecoder from .logging import CONSOLE_LOGGER def convert_pascal_to_camel(s: str): - """Converts a string to camel_case.""" - sections = re.split("(?<=.)(?=[A-Z])", s) #zero-length match before capitals, skipping capital at the 0th index - parts = [] - for sec in sections: - parts.append(sec.lower()) + """Converts a string to camel_case.""" + sections = re.split("(?<=.)(?=[A-Z])", s) #zero-length match before capitals, skipping capital at the 0th index + parts = [] + for sec in sections: + parts.append(sec.lower()) - return parts.join("_") + return "_".join(parts) def convert_camel_to_pascal(s: str): """Converts a string to PascalCase, ignoring _ if it is the first character.""" @@ -20,102 +20,213 @@ def convert_camel_to_pascal(s: str): parts = [] for sec in sections: #capitalise each section unless the section is empty (in which case, append an underscore) or the section starts with an underscore (first section can begin with _) - if sec == "": - parts.append("_") - continue - elif sec.startswith("_"): - parts.append(sec) + if sec == "": + parts.append("_") + continue + elif sec.startswith("_"): + parts.append(sec) parts.append(sec.capitalize()) return ''.join(parts) class BHoMJSONDecoder(JSONDecoder): - def __init__(self, *args, **kwargs): - super().__init__(self, object_hook=self.object_hook, *args, **kwargs) - - def object_hook(self, d): - if "_t" not in d: - CONSOLE_LOGGER.debug(f"BHoMJSONDecoder could not convert the following dictionary into a BHoMObject due to a missing '_t' property. Falling back to dictionary: {d}") - return d - - #get default BHoMObject properties and replace with defaults if not present - name = d.pop("Name", "") - bhom_guid = d.pop("BHoM_Guid", uuid.uuid4()) - tags = d.pop("Tags", []) - fragments = d.pop("Fragments", {}) - custom_data = d.pop("CustomData", {}) - _t = d.pop("_t") - - #convert all properties to camel_case as python users expect - props = {} - for prop_name in d: - props[convert_pascal_to_camel(prop_name)] = d[prop_name] - - return BHoMObject(name, bhom_guid, tags, fragments, custom_data, _t, **props) + def __init__(self, *args, **kwargs): + json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, d): + if "_t" not in d: + CONSOLE_LOGGER.debug(f"BHoMJSONDecoder could not convert the following dictionary into a BHoMObject due to a missing '_t' property. Falling back to dictionary: {d}") + return d + + props = { + "_t": d.pop("_t"), + "_bhom_version": d.pop("_bhomVersion", None) + } + + if d.get("BHoM_Guid", None) is not None: + #deserialise as BHoM Object + + #get default BHoMObject properties and replace with defaults if not present + props["name"] = d.pop("Name", "") + props["bhom_guid"] = uuid.UUID(d.pop("BHoM_Guid")) + props["tags"] = d.pop("Tags", []) + props["fragments"] = d.pop("Fragments", []) + props["custom_data"] = d.pop("CustomData", {}) + + #convert all other properties to camel_case as python users expect + for prop_name in d: + props[convert_pascal_to_camel(prop_name)] = d[prop_name] + + return BHoMObject(**props) + else: + #deserialise as IObject + for prop_name in d: + props[convert_pascal_to_camel(prop_name)] = d[prop_name] + + return IObject(**props) class BHoMJSONEncoder(JSONEncoder): - def default(self, o): - if isinstance(o, BHoMObject): - #initialise special BHoMObject properties - props = { - "Name": o.name, - "BHoM_Guid": o.bhom_guid, - "Tags": o.tags, - "Fragments": o.fragments, - "CustomData": o.custom_data, - "_t": o._t - } - - #get property names with reflection and convert all properties to PascalCase as the BHoM serialiser expects - for prop_name, value in vars(o).items(): - if prop_name in ["name", "bhom_guid", "tags", "fragments", "custom_data", "_t"] - continue - props[convert_camel_to_pascal(prop_name)] = value - - return props - - return super(BHoMJSONDecoder, self).default(o) #fallback to default json decoder if object is not a BHoMObject (don't convert property case). - -class BHoMObject: - name: str - bhom_guid: uuid.UUID - tags: List[str] - fragments: Dict[type, object] - custom_data: Dict[str, object] - _t: str - - def __init__( - self, - name: str = "", - bhom_guid: uuid.UUID = uuid.uuid4(), - tags: List[str] = [], - fragments: Dict[type, object] = {}, - custom_data: Dict[str, object] = {}, - _t: str = "BH.oM.Base.BHoMObject" - **kwargs - ) -> BHoMObject: - - self.name = name - self.bhom_guid = bhom_guid - self.fragments = fragments - self.tags = tags - self.custom_data = custom_data - self._t = _t - - #set non-CustomData properties with reflection. - for kwarg in kwargs: - setattr(self, kwarg, kwargs[kwarg]) - - @classmethod - def from_json(j: str) -> 'BHoMObject': - obj = json.loads(json, decoder=BHoMJSONDecoder) - - if not isinstance(obj, BHoMObject): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. - raise TypeError("The object provided does not deserialise to a valid BHoM object.") - - return obj - - def to_json(self) -> str: - s = json.dumps(self, encoder=BHoMJSONEncoder) - return s \ No newline at end of file + def default(self, o): + if isinstance(o, BHoMObject): + #initialise special BHoMObject properties + + props = { + "Name": o.name, + "BHoM_Guid": str(o.bhom_guid), + "_t": o._t + } + + if len(o.tags) > 0: + props["Tags"] = o.tags + + if len(o.fragments) > 0: + props["Fragments"] = o.fragments + + if len(o.custom_data) > 0: + props["CustomData"] = o.custom_data + + if o._bhom_version is not None: + props["_bhomVersion"] = o._bhom_version + + #get property names with reflection and convert all properties to PascalCase as the BHoM serialiser expects + for prop_name, value in vars(o).items(): + if prop_name in ["name", "bhom_guid", "tags", "fragments", "custom_data", "_t", "_bhom_version"]: + continue + + props[convert_camel_to_pascal(prop_name)] = value + + return props + elif isinstance(o, IObject): + props = { + "_t": o._t + } + + if o._bhom_version is not None: + props["_bhomVersion"] = o._bhom_version + + for prop_name, value in vars(o).items(): + if prop_name in ["_t", "_bhom_version"]: + continue + + props[convert_camel_to_pascal(prop_name)] = value + + return props + elif isinstance(o, uuid.UUID): #UUID object is not json serialisable by default + return str(o) + + return super(type(self), self).default(o) #fallback to default json decoder if object is not a BHoMObject (don't convert property case). + +class IObject: + """More generic version of BHoMObject, for non-native objects serialised by the BHoM serialiser, but do not inherit from BHoMObject.""" + _t: str + _bhom_version: str + + def __init__( + self, + _t: str, + _bhom_version: str = None, + **kwargs + ) -> 'IObject': + self._t = _t + self._bhom_version = _bhom_version + + #set properties with reflection. + for kwarg in kwargs: + setattr(self, kwarg, kwargs[kwarg]) + + def __repr__(self) -> str: + return f"{type(self).__name__} of type {self._t}, version: '{getattr(self, "_bhom_version", "Unknown")}'" + + def __eq__(self, other) -> bool: + if not isinstance(other, IObject): + return False + + if self._t != other._t: + return False + + vself = vars(self).copy() + vother = vars(other).copy() + + #ignore these properties when comparing by property. + ignore = ["_bhom_version"] + _ = [(vself.pop(p, None), vother.pop(p, None)) for p in ignore] + + return vself == vother + + @classmethod + def from_json(cls, j: str) -> 'IObject': + obj = json.loads(j, cls=BHoMJSONDecoder) + + if not isinstance(obj, cls): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. + raise TypeError("The object provided does not deserialise to a valid BHoM object.") + + return obj + + def to_json(self) -> str: + return json.dumps(self, cls=BHoMJSONEncoder) + +class BHoMObject(IObject): + name: str + bhom_guid: uuid.UUID + tags: List[str] + fragments: List[Dict[str, object]] + custom_data: Dict[str, object] + + def __init__( + self, + _t: str, #don't make default, as subclasses of this class should set this with super().__init__ + name: str = "", + bhom_guid: uuid.UUID = uuid.uuid4(), + tags: List[str] = [], + fragments: List[Dict[str, object]] = [], + custom_data: Dict[str, object] = {}, + _bhom_version: str = None, + **kwargs + ) -> 'BHoMObject': + self._t = _t + self.name = name + self.bhom_guid = bhom_guid + self.fragments = fragments + self.tags = tags + self.custom_data = custom_data + self._bhom_version = _bhom_version + + #set non-CustomData properties with reflection. + for kwarg in kwargs: + setattr(self, kwarg, kwargs[kwarg]) + + def __repr__(self) -> str: + return f"{type(self).__name__} of type {self._t}, name: '{self.name}', version: '{getattr(self, "_bhom_version", "Unknown")}', id: '{self.bhom_guid}'" + + def __eq__(self, other) -> bool: + if not isinstance(other, BHoMObject): + return False + + if self._t != other._t: + return False + + vself = vars(self).copy() + vother = vars(other).copy() + + #ignore these properties when comparing by property. + ignore = ["bhom_guid", "_bhom_version"] + _ = [(vself.pop(p, None), vother.pop(p, None)) for p in ignore] + + return vself == vother + + @classmethod + def from_json(cls, j: str): + obj = json.loads(j, cls=BHoMJSONDecoder) + + if issubclass(cls, BHoMObject) and cls != BHoMObject: + obj = cls._from_bhom_object(obj) + + if not isinstance(obj, cls): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. + raise TypeError("The object provided does not deserialise to a valid BHoM object.") + + return obj + + @classmethod + def _from_bhom_object(cls, o: 'BHoMObject'): + return cls(**vars(o).copy()) #assuming that the sub class is correctly set up, then this should work \ No newline at end of file diff --git a/Python_Engine/Python/tests/test_bhom_serialiser.py b/Python_Engine/Python/tests/test_bhom_serialiser.py index 92e6b62b..15f69f80 100644 --- a/Python_Engine/Python/tests/test_bhom_serialiser.py +++ b/Python_Engine/Python/tests/test_bhom_serialiser.py @@ -1,11 +1,13 @@ from python_toolkit.bhom.bhom_object import convert_pascal_to_camel, convert_camel_to_pascal, BHoMObject, BHoMJSONDecoder, BHoMJSONEncoder +import uuid import json -SERIALISED_BHOM_OBJECT = '{"_t": "BH.oM.Base.BHoMObject", "Name": "test_object"}' #TODO: use the BHoM serialiser to make a BHoMObject json string, and use that here. -DESERIALISED_BHOM_OBJECT = BHoMObject(name = "test_object") #TODO: make equivalent BHoMObject here identical to the one above. +SERIALISED_BHOM_OBJECT = '{"_t": "BH.oM.Base.BHoMObject", "Name": "test_object", "BHoM_Guid": "91ec5ba6-88cd-4b3c-b12a-3d88f0252cde"}' #TODO: use the BHoM serialiser to make a BHoMObject json string, and use that here. +DESERIALISED_BHOM_OBJECT = BHoMObject(_t = "BH.oM.Base.BHoMObject", bhom_guid=uuid.UUID("91ec5ba6-88cd-4b3c-b12a-3d88f0252cde"), name = "test_object") #TODO: make equivalent BHoMObject here identical to the one above. def test_case_convert(): """Test that the camel and pascal converters are working correctly by using expected outputs and a round trip both ways.""" + #TODO: find edge cases within bhom to see if round trip converters work properly. #arrange test_pascal_str = "ThisIsAPascalCaseString" @@ -26,22 +28,43 @@ def test_case_convert(): assert pascal_round_trip == test_pascal_str, f"pascal round trip got '{pascal_round_trip}' but expected '{test_pascal_str}'." assert camel_round_trip == test_camel_str, f"camel round trip got '{camel_round_trip}' but expected '{test_camel_str}'." -def test_serialise_bhom_object() +def test_serialise_bhom_object(): """Test that bhom objects serialise correctly to a format that the c# bhom serialiser accepts as valid, and with the correct property case.""" - serialised = json.dumps(DESERIALISED_BHOM_OBJECT, encoder=BHoMJSONEncoder) + serialised = json.dumps(DESERIALISED_BHOM_OBJECT, cls=BHoMJSONEncoder) serialised_bhom_object_to_json = DESERIALISED_BHOM_OBJECT.to_json() round_trip = BHoMObject.from_json(serialised) assert serialised == serialised_bhom_object_to_json, f"Direct serialisation to json differed to BHoMObject to_json method." assert round_trip == DESERIALISED_BHOM_OBJECT, f"BHoMObject round trip failed for serialisation -> deserialisation." #this is the only direction the round trip can be tested without directly inspecting each dictionary entry, as it is not guaranteed that the other direction will produce identical order for json strings. -def test_deserialise_bhom_object() +def test_deserialise_bhom_object(): """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" - obj = json.loads(SERIALISED_BHOM_OBJECT, decoder=BHoMJSONDecoder) + obj = json.loads(SERIALISED_BHOM_OBJECT, cls=BHoMJSONDecoder) obj_bhom_object_from_json = BHoMObject.from_json(SERIALISED_BHOM_OBJECT) assert isinstance(obj, BHoMObject), "JSON decoded an object of the wrong type" assert obj == DESERIALISED_BHOM_OBJECT, f"Actual deserialised object ({obj}) was not identical to expected deserialised object ({DESERIALISED_BHOM_OBJECT})." - assert obj_bhom_object_from_json == obj, f"Direct deserialisation from json differed to BHoMObject from_json method." \ No newline at end of file + assert obj_bhom_object_from_json == obj, f"Direct deserialisation from json differed to BHoMObject from_json method." + +def test_subclass(): + + class TestSubClass(BHoMObject): + _t: str = "BH.oM.Base.CustomObject" + some_other_data: str + + def __init__(self, some_other_data, **kwargs): + self.some_other_data = some_other_data + _t = kwargs.pop("_t", self._t) + super().__init__(_t, **kwargs) + + test_object = TestSubClass("this is some test data") + + test_object_json = test_object.to_json() + + test_object_round_trip = TestSubClass.from_json(test_object_json) + + assert test_object == test_object_round_trip + assert test_object.some_other_data == "this is some test data" + assert test_object._t == "BH.oM.Base.CustomObject" \ No newline at end of file From aa0a24173ab1076a1efdbd4d9a96b0e24a998526 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 28 May 2026 11:28:41 +0100 Subject: [PATCH 28/50] added warning if mismatched bhom versions --- Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 0dc10b4f..66910bcd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -4,6 +4,7 @@ import json from json import JSONEncoder, JSONDecoder from .logging import CONSOLE_LOGGER +from . import BHOM_VERSION def convert_pascal_to_camel(s: str): """Converts a string to camel_case.""" @@ -44,6 +45,9 @@ def object_hook(self, d): "_bhom_version": d.pop("_bhomVersion", None) } + if (props["_bhom_version"] is not None) and (props["_bhom_version"] != ".".join(BHOM_VERSION.split(".")[0:1])) + CONSOLE_LOGGER.warning(f"The bhom version specified in the encoded json ({props["_bhom_version"]}) is different from the BHoM version that python_toolkit was installed with ({BHOM_VERSION}). There may be versioning issues with this object. Consider deserialising and then serialising again with the BHoM serialiser to get the correct version, or update BHoM to the correct version.") + if d.get("BHoM_Guid", None) is not None: #deserialise as BHoM Object From 19b9ef28052359b8af16b051ed99ffe0c5577d1a Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 28 May 2026 11:34:40 +0100 Subject: [PATCH 29/50] I hate not having an interpreter available --- Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 66910bcd..4d62301e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -45,7 +45,7 @@ def object_hook(self, d): "_bhom_version": d.pop("_bhomVersion", None) } - if (props["_bhom_version"] is not None) and (props["_bhom_version"] != ".".join(BHOM_VERSION.split(".")[0:1])) + if (props["_bhom_version"] is not None) and (props["_bhom_version"] != ".".join(BHOM_VERSION.split(".")[0:1])): CONSOLE_LOGGER.warning(f"The bhom version specified in the encoded json ({props["_bhom_version"]}) is different from the BHoM version that python_toolkit was installed with ({BHOM_VERSION}). There may be versioning issues with this object. Consider deserialising and then serialising again with the BHoM serialiser to get the correct version, or update BHoM to the correct version.") if d.get("BHoM_Guid", None) is not None: From 0c08130fe14b19e8ba00d30a7e7f0d66558cf410 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 28 May 2026 11:39:30 +0100 Subject: [PATCH 30/50] use two version numbers instead of one :) --- Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 4d62301e..35bfbc28 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -45,7 +45,7 @@ def object_hook(self, d): "_bhom_version": d.pop("_bhomVersion", None) } - if (props["_bhom_version"] is not None) and (props["_bhom_version"] != ".".join(BHOM_VERSION.split(".")[0:1])): + if (props["_bhom_version"] is not None) and (props["_bhom_version"] != ".".join(BHOM_VERSION.split(".")[0:2])): CONSOLE_LOGGER.warning(f"The bhom version specified in the encoded json ({props["_bhom_version"]}) is different from the BHoM version that python_toolkit was installed with ({BHOM_VERSION}). There may be versioning issues with this object. Consider deserialising and then serialising again with the BHoM serialiser to get the correct version, or update BHoM to the correct version.") if d.get("BHoM_Guid", None) is not None: From 6ffbddfee861cc2c63d3267b00e4b80a9d018612 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Mon, 1 Jun 2026 10:22:20 +0100 Subject: [PATCH 31/50] improve error messages and fix bugs found by new test scripts/methods, and handle json deserialisation of lists (as BHoM serialiser in grasshopper likes to make json arrays) --- .../src/python_toolkit/bhom/bhom_object.py | 40 ++++++++++++++--- .../Python/tests/test_bhom_serialiser.py | 43 +++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 35bfbc28..cf4fbfbc 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -5,6 +5,9 @@ from json import JSONEncoder, JSONDecoder from .logging import CONSOLE_LOGGER from . import BHOM_VERSION +import pandas as pd + +BHOM_SHORT_VERSION = ".".join(BHOM_VERSION.split(".")[0:2]) def convert_pascal_to_camel(s: str): """Converts a string to camel_case.""" @@ -42,11 +45,11 @@ def object_hook(self, d): props = { "_t": d.pop("_t"), - "_bhom_version": d.pop("_bhomVersion", None) + "_bhom_version": d.pop("_bhomVersion", BHOM_SHORT_VERSION) } - if (props["_bhom_version"] is not None) and (props["_bhom_version"] != ".".join(BHOM_VERSION.split(".")[0:2])): - CONSOLE_LOGGER.warning(f"The bhom version specified in the encoded json ({props["_bhom_version"]}) is different from the BHoM version that python_toolkit was installed with ({BHOM_VERSION}). There may be versioning issues with this object. Consider deserialising and then serialising again with the BHoM serialiser to get the correct version, or update BHoM to the correct version.") + if (props["_bhom_version"] is not None) and (props["_bhom_version"] != BHOM_SHORT_VERSION): + CONSOLE_LOGGER.warning(f"The bhom version specified in the encoded json ({props['_bhom_version']}) is different from the BHoM version that python_toolkit was installed with ({BHOM_SHORT_VERSION}). There may be versioning issues with this object. Consider deserialising and then serialising again with the BHoM serialiser to get the correct version, or update BHoM to the correct version.") if d.get("BHoM_Guid", None) is not None: #deserialise as BHoM Object @@ -64,6 +67,9 @@ def object_hook(self, d): return BHoMObject(**props) else: + if props["_t"].startswith("System.Collections.Generic.List"): #this handles when the BHoM serialiser makes generic lists from CustomObject list properties, which get converted to a dictionary. The BHoM serialiser does recognise lists if it can find the object definition via reflection. + return d["_v"] + #deserialise as IObject for prop_name in d: props[convert_pascal_to_camel(prop_name)] = d[prop_name] @@ -118,7 +124,9 @@ def default(self, o): return props elif isinstance(o, uuid.UUID): #UUID object is not json serialisable by default return str(o) - + elif isinstance(o, pd.DatetimeIndex): + return [date.isoformat() for date in o] + return super(type(self), self).default(o) #fallback to default json decoder if object is not a BHoMObject (don't convert property case). class IObject: @@ -140,7 +148,7 @@ def __init__( setattr(self, kwarg, kwargs[kwarg]) def __repr__(self) -> str: - return f"{type(self).__name__} of type {self._t}, version: '{getattr(self, "_bhom_version", "Unknown")}'" + return f"{type(self).__name__} of type {self._t}, version: '{getattr(self, '_bhom_version', 'Unknown')}'" def __eq__(self, other) -> bool: if not isinstance(other, IObject): @@ -201,7 +209,7 @@ def __init__( setattr(self, kwarg, kwargs[kwarg]) def __repr__(self) -> str: - return f"{type(self).__name__} of type {self._t}, name: '{self.name}', version: '{getattr(self, "_bhom_version", "Unknown")}', id: '{self.bhom_guid}'" + return f"{type(self).__name__} of type {self._t}, name: '{self.name}', version: '{getattr(self, '_bhom_version', 'Unknown')}', id: '{self.bhom_guid}'" def __eq__(self, other) -> bool: if not isinstance(other, BHoMObject): @@ -223,6 +231,10 @@ def __eq__(self, other) -> bool: def from_json(cls, j: str): obj = json.loads(j, cls=BHoMJSONDecoder) + if isinstance(obj, list): + CONSOLE_LOGGER.warning("The root element of the JSON provided was a list, assuming that the first item is the desired object. If you intended to deserialise a list, please use the `.from_json_array(j)` method instead.") + obj = obj[0] + if issubclass(cls, BHoMObject) and cls != BHoMObject: obj = cls._from_bhom_object(obj) @@ -231,6 +243,22 @@ def from_json(cls, j: str): return obj + @classmethod + def from_json_array(cls, j: str): + objs = json.loads(j, cls=BHoMJSONDecoder) + + if not isinstance(objs, list): + raise TypeError("The root element of the JSON provided was not a JSON array. Perhaps you intended to use `.from_json(j)` instead?") + + out = [] + for obj in objs: + if issubclass(cls, BHoMObject) and cls != BHoMObject: + out.append(cls._from_bhom_object(obj)) + continue + out.append(obj) + + return out + @classmethod def _from_bhom_object(cls, o: 'BHoMObject'): return cls(**vars(o).copy()) #assuming that the sub class is correctly set up, then this should work \ No newline at end of file diff --git a/Python_Engine/Python/tests/test_bhom_serialiser.py b/Python_Engine/Python/tests/test_bhom_serialiser.py index 15f69f80..0d1385f3 100644 --- a/Python_Engine/Python/tests/test_bhom_serialiser.py +++ b/Python_Engine/Python/tests/test_bhom_serialiser.py @@ -1,14 +1,16 @@ -from python_toolkit.bhom.bhom_object import convert_pascal_to_camel, convert_camel_to_pascal, BHoMObject, BHoMJSONDecoder, BHoMJSONEncoder +from python_toolkit.bhom.bhom_object import convert_pascal_to_camel, convert_camel_to_pascal, BHoMObject, IObject, BHoMJSONDecoder, BHoMJSONEncoder import uuid import json -SERIALISED_BHOM_OBJECT = '{"_t": "BH.oM.Base.BHoMObject", "Name": "test_object", "BHoM_Guid": "91ec5ba6-88cd-4b3c-b12a-3d88f0252cde"}' #TODO: use the BHoM serialiser to make a BHoMObject json string, and use that here. -DESERIALISED_BHOM_OBJECT = BHoMObject(_t = "BH.oM.Base.BHoMObject", bhom_guid=uuid.UUID("91ec5ba6-88cd-4b3c-b12a-3d88f0252cde"), name = "test_object") #TODO: make equivalent BHoMObject here identical to the one above. +SERIALISED_BHOM_OBJECT = '{ "_t" : "BH.oM.Adapter.FileSettings", "FileName" : "test.txt", "Directory" : "path/to/file", "BHoM_Guid" : "f428e614-bda1-4228-882e-21d0f318a322", "Name" : "", "_bhomVersion" : "9.2" }' #TODO: use the BHoM serialiser to make a BHoMObject json string, and use that here. +DESERIALISED_BHOM_OBJECT = BHoMObject(_t = "BH.oM.Adapter.FileSettings", bhom_guid = uuid.UUID("f428e614-bda1-4228-882e-21d0f318a322"), file_name = "test.txt", directory = "path/to/file", _bhom_version = "9.2") #TODO: make equivalent BHoMObject here identical to the one above. + +SERIALISED_IOBJECT = '{ "_t" : "BH.oM.Geometry.Point", "X" : 0.10000000000000001, "Y" : 0.10000000000000001, "Z" : 0.10000000000000001, "_bhomVersion" : "9.2" }' +DESERIALISED_IOBJECT = IObject(_t = "BH.oM.Geometry.Point", x = 0.10000000000000001, y = 0.10000000000000001, z = 0.10000000000000001, _bhom_version = "9.2") def test_case_convert(): """Test that the camel and pascal converters are working correctly by using expected outputs and a round trip both ways.""" #TODO: find edge cases within bhom to see if round trip converters work properly. - #arrange test_pascal_str = "ThisIsAPascalCaseString" test_camel_str = "this_is_a_camel_case_string" @@ -30,26 +32,49 @@ def test_case_convert(): def test_serialise_bhom_object(): """Test that bhom objects serialise correctly to a format that the c# bhom serialiser accepts as valid, and with the correct property case.""" - + #act serialised = json.dumps(DESERIALISED_BHOM_OBJECT, cls=BHoMJSONEncoder) serialised_bhom_object_to_json = DESERIALISED_BHOM_OBJECT.to_json() round_trip = BHoMObject.from_json(serialised) + #assert assert serialised == serialised_bhom_object_to_json, f"Direct serialisation to json differed to BHoMObject to_json method." assert round_trip == DESERIALISED_BHOM_OBJECT, f"BHoMObject round trip failed for serialisation -> deserialisation." #this is the only direction the round trip can be tested without directly inspecting each dictionary entry, as it is not guaranteed that the other direction will produce identical order for json strings. +def test_serialise_iobject(): + #act + serialised = json.dumps(DESERIALISED_IOBJECT, cls=BHoMJSONEncoder) + serialised_iobject_to_json = DESERIALISED_IOBJECT.to_json() + round_trip = IObject.from_json(serialised) + + #assert + assert serialised == serialised_iobject_to_json, f"Direct serialisation to json differed to IObject to_json method." + assert round_trip == DESERIALISED_IOBJECT, f"IObject round trip failed for serialisation -> deserialisation." #this is the only direction the round trip can be tested without directly inspecting each dictionary entry, as it is not guaranteed that the other direction will produce identical order for json strings. + def test_deserialise_bhom_object(): """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" - + #act obj = json.loads(SERIALISED_BHOM_OBJECT, cls=BHoMJSONDecoder) obj_bhom_object_from_json = BHoMObject.from_json(SERIALISED_BHOM_OBJECT) + #assert assert isinstance(obj, BHoMObject), "JSON decoded an object of the wrong type" assert obj == DESERIALISED_BHOM_OBJECT, f"Actual deserialised object ({obj}) was not identical to expected deserialised object ({DESERIALISED_BHOM_OBJECT})." assert obj_bhom_object_from_json == obj, f"Direct deserialisation from json differed to BHoMObject from_json method." +def test_deserialise_iobject(): + """Test that bhom objects deserialise correctly with no errors with expected properties with correct case.""" + #act + obj = json.loads(SERIALISED_IOBJECT, cls=BHoMJSONDecoder) + obj_iobject_from_json = IObject.from_json(SERIALISED_IOBJECT) + + #assert + assert isinstance(obj, IObject), "JSON decoded an object of the wrong type" + assert obj == DESERIALISED_IOBJECT, f"Actual deserialised object ({obj}) was not identical to expected deserialised object ({DESERIALISED_IOBJECT})." + assert obj_iobject_from_json == obj, f"Direct deserialisation from json differed to IObject from_json method." + def test_subclass(): - + #arrange class TestSubClass(BHoMObject): _t: str = "BH.oM.Base.CustomObject" some_other_data: str @@ -59,12 +84,12 @@ def __init__(self, some_other_data, **kwargs): _t = kwargs.pop("_t", self._t) super().__init__(_t, **kwargs) + #act test_object = TestSubClass("this is some test data") - test_object_json = test_object.to_json() - test_object_round_trip = TestSubClass.from_json(test_object_json) + #assert assert test_object == test_object_round_trip assert test_object.some_other_data == "this is some test data" assert test_object._t == "BH.oM.Base.CustomObject" \ No newline at end of file From 6e43f8ff7066c9f5d703935be60cd078f184d8eb Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Mon, 1 Jun 2026 16:12:56 +0100 Subject: [PATCH 32/50] add from and to_dict methods to IObject to enable subclasses to use dictionary versions for converting between objects (for instance for the ladybugtools python package --- .../Python/src/python_toolkit/bhom/bhom_object.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index cf4fbfbc..d691b314 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -6,6 +6,7 @@ from .logging import CONSOLE_LOGGER from . import BHOM_VERSION import pandas as pd +import copy BHOM_SHORT_VERSION = ".".join(BHOM_VERSION.split(".")[0:2]) @@ -178,6 +179,19 @@ def from_json(cls, j: str) -> 'IObject': def to_json(self) -> str: return json.dumps(self, cls=BHoMJSONEncoder) + @classmethod + def from_dict(cls, d: dict) -> 'IObject' + try: + return cls(**d) #should be valid as long as the dictionary has all necessary entries. + except ArgumentError as ae: + raise ArgumentError("Input dictionary was missing some required arguments, see traceback for more information.") from ae + + def to_dict(self) + """Convert this IObject to a dictionary via a json round-trip.""" + j = self.to_json(default=str) + d = json.loads(j) + return d + class BHoMObject(IObject): name: str bhom_guid: uuid.UUID From 59279c05d8f2fef9f76f90db1ea5c6230ad7450e Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 2 Jun 2026 11:14:11 +0100 Subject: [PATCH 33/50] save point as changing laptops --- .../Python/src/python_toolkit/bhom/bhom_object.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index d691b314..a01a7a2c 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -127,6 +127,8 @@ def default(self, o): return str(o) elif isinstance(o, pd.DatetimeIndex): return [date.isoformat() for date in o] + elif hasattr(o, "to_json") and callable(getattr(o, "to_json")): #custom handle classes that might have their own json converters + return o.to_json() return super(type(self), self).default(o) #fallback to default json decoder if object is not a BHoMObject (don't convert property case). @@ -180,14 +182,14 @@ def to_json(self) -> str: return json.dumps(self, cls=BHoMJSONEncoder) @classmethod - def from_dict(cls, d: dict) -> 'IObject' + def from_dict(cls, d: dict) -> 'IObject': try: return cls(**d) #should be valid as long as the dictionary has all necessary entries. except ArgumentError as ae: raise ArgumentError("Input dictionary was missing some required arguments, see traceback for more information.") from ae - def to_dict(self) - """Convert this IObject to a dictionary via a json round-trip.""" + def to_dict(self) -> dict: + """Convert this IObject to a dictionary via a json round-trip.""" j = self.to_json(default=str) d = json.loads(j) return d From cef5c1b58af7e824a126ce235632b50d43a02c3a Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 2 Jun 2026 15:40:56 +0100 Subject: [PATCH 34/50] add a few default converters to json format for common non-serialisable types and fixed some bugs --- .../src/python_toolkit/bhom/bhom_object.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index a01a7a2c..bb2bdc07 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -1,3 +1,5 @@ +from ctypes import ArgumentError +from pathlib import Path import uuid import re from typing import List, Dict @@ -6,7 +8,7 @@ from .logging import CONSOLE_LOGGER from . import BHOM_VERSION import pandas as pd -import copy +import numpy as np BHOM_SHORT_VERSION = ".".join(BHOM_VERSION.split(".")[0:2]) @@ -101,7 +103,7 @@ def default(self, o): props["_bhomVersion"] = o._bhom_version #get property names with reflection and convert all properties to PascalCase as the BHoM serialiser expects - for prop_name, value in vars(o).items(): + for prop_name, value in vars(o).copy().items(): if prop_name in ["name", "bhom_guid", "tags", "fragments", "custom_data", "_t", "_bhom_version"]: continue @@ -116,21 +118,32 @@ def default(self, o): if o._bhom_version is not None: props["_bhomVersion"] = o._bhom_version - for prop_name, value in vars(o).items(): + for prop_name, value in vars(o).copy().items(): if prop_name in ["_t", "_bhom_version"]: continue props[convert_camel_to_pascal(prop_name)] = value return props + #handle common non-serialisable types elif isinstance(o, uuid.UUID): #UUID object is not json serialisable by default return str(o) elif isinstance(o, pd.DatetimeIndex): return [date.isoformat() for date in o] - elif hasattr(o, "to_json") and callable(getattr(o, "to_json")): #custom handle classes that might have their own json converters - return o.to_json() + elif isinstance(o, np.ndarray): + return o.tolist() + elif isinstance(o, pd.Timestamp): + return o.isoformat() + elif isinstance(o, pd.Series): + return dict(zip(o.index.astype(str), o)) + elif isinstance(o, Path): + return str(o) + elif hasattr(o, "to_dict") and callable(getattr(o, "to_dict")): #custom handle classes that have their own dict converters + return o.to_dict() + elif hasattr(o, "__dict__"): + return vars(o).copy() - return super(type(self), self).default(o) #fallback to default json decoder if object is not a BHoMObject (don't convert property case). + return super(type(self), self).default(o) #fallback to default json decoder (ValueError) if object is not a BHoMObject or common serialisable type. class IObject: """More generic version of BHoMObject, for non-native objects serialised by the BHoM serialiser, but do not inherit from BHoMObject.""" From 12fc88be63892a3fb1b16c417c1258ed79e3fc2d Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 4 Jun 2026 15:38:05 +0100 Subject: [PATCH 35/50] add extra method called by both decoder and encoder to more easily allow subclassing of the BHoM serialisers, and changed to_dict() mthod to deepcopy --- .../src/python_toolkit/bhom/bhom_object.py | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index bb2bdc07..61d564dd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -1,8 +1,11 @@ +from base64 import decode +import copy from ctypes import ArgumentError +from json import encoder from pathlib import Path import uuid import re -from typing import List, Dict +from typing import Any, List, Dict, Union import json from json import JSONEncoder, JSONDecoder from .logging import CONSOLE_LOGGER @@ -44,7 +47,7 @@ def __init__(self, *args, **kwargs): def object_hook(self, d): if "_t" not in d: CONSOLE_LOGGER.debug(f"BHoMJSONDecoder could not convert the following dictionary into a BHoMObject due to a missing '_t' property. Falling back to dictionary: {d}") - return d + return self.deserialise_unknown(d) props = { "_t": d.pop("_t"), @@ -68,7 +71,7 @@ def object_hook(self, d): for prop_name in d: props[convert_pascal_to_camel(prop_name)] = d[prop_name] - return BHoMObject(**props) + return self.deserialise_unknown(BHoMObject(**props)) else: if props["_t"].startswith("System.Collections.Generic.List"): #this handles when the BHoM serialiser makes generic lists from CustomObject list properties, which get converted to a dictionary. The BHoM serialiser does recognise lists if it can find the object definition via reflection. return d["_v"] @@ -77,7 +80,12 @@ def object_hook(self, d): for prop_name in d: props[convert_pascal_to_camel(prop_name)] = d[prop_name] - return IObject(**props) + return self.deserialise_unknown(IObject(**props)) + + def deserialise_unknown(self, obj: Union['IObject', 'BHoMObject', dict]): + """Override this method in a subclass to add extra object hook on top of the existing method. If the object is an object serialised by the BHoM serialiser, the output object will be a BHoMObject or IObject, so use isinstance() to select.""" + return obj + class BHoMJSONEncoder(JSONEncoder): def default(self, o): @@ -137,13 +145,19 @@ def default(self, o): elif isinstance(o, pd.Series): return dict(zip(o.index.astype(str), o)) elif isinstance(o, Path): - return str(o) - elif hasattr(o, "to_dict") and callable(getattr(o, "to_dict")): #custom handle classes that have their own dict converters - return o.to_dict() - elif hasattr(o, "__dict__"): - return vars(o).copy() + return { "FileName": str(o.name), "Directory": str(o.parent), "_t": "BH.oM.Adapter.FileSettings", "BHoM_Guid": str(uuid.uuid4()) } + + return self.serialise_unknown(o) #if the object is unknown at this point, call serialise_unknown to allow subclasses to add their own serialisation logic. + + def serialise_unknown(self, obj: Any) -> Union[Dict[str, str], str, List[str], Any]: + """ + This is called by the BHoMJSONEncoder to serialise unknown objects so that the BHoM can handle them if they exist as BHoM Objects in c# but not in python (i.e. an object from an external library). + + Override this method in a sub class with its own logic, and call super().serialise_unknown(obj) for objects that are unknown, or do not wish to serialise. + The following objects won't be passed to this method: BHoMObject, IObject, uuid.UUID, pandas.DatetimeIndex, numpy.ndarray, pandas.Timestamp, pandas.Series, pathlib.Path + """ + return super(type(self), self).default(obj) #fallback to default json decoder (ValueError) if object is not a BHoMObject or common serialisable type. - return super(type(self), self).default(o) #fallback to default json decoder (ValueError) if object is not a BHoMObject or common serialisable type. class IObject: """More generic version of BHoMObject, for non-native objects serialised by the BHoM serialiser, but do not inherit from BHoMObject.""" @@ -183,16 +197,16 @@ def __eq__(self, other) -> bool: return vself == vother @classmethod - def from_json(cls, j: str) -> 'IObject': - obj = json.loads(j, cls=BHoMJSONDecoder) + def from_json(cls, j: str, decoder_class: type = BHoMJSONDecoder) -> 'IObject': + obj = json.loads(j, cls=decoder_class) if not isinstance(obj, cls): #this only tests that the top level object was deserialised correctly, if there are problems with deep properties, change the CONSOLE_LOGGER log level to debug. raise TypeError("The object provided does not deserialise to a valid BHoM object.") return obj - def to_json(self) -> str: - return json.dumps(self, cls=BHoMJSONEncoder) + def to_json(self, encoder_class: type = BHoMJSONEncoder) -> str: + return json.dumps(self, cls=encoder_class) @classmethod def from_dict(cls, d: dict) -> 'IObject': @@ -201,11 +215,9 @@ def from_dict(cls, d: dict) -> 'IObject': except ArgumentError as ae: raise ArgumentError("Input dictionary was missing some required arguments, see traceback for more information.") from ae - def to_dict(self) -> dict: - """Convert this IObject to a dictionary via a json round-trip.""" - j = self.to_json(default=str) - d = json.loads(j) - return d + def to_dict(self, encoder_class: type = BHoMJSONEncoder) -> dict: + """Convert this IObject to a dictionary via deep copying vars(self).""" + return copy.deepcopy(vars(self)) class BHoMObject(IObject): name: str @@ -257,8 +269,8 @@ def __eq__(self, other) -> bool: return vself == vother @classmethod - def from_json(cls, j: str): - obj = json.loads(j, cls=BHoMJSONDecoder) + def from_json(cls, j: str, decoder_class: type = BHoMJSONDecoder): + obj = json.loads(j, cls=decoder_class) if isinstance(obj, list): CONSOLE_LOGGER.warning("The root element of the JSON provided was a list, assuming that the first item is the desired object. If you intended to deserialise a list, please use the `.from_json_array(j)` method instead.") @@ -273,8 +285,8 @@ def from_json(cls, j: str): return obj @classmethod - def from_json_array(cls, j: str): - objs = json.loads(j, cls=BHoMJSONDecoder) + def from_json_array(cls, j: str, decoder_class: type = BHoMJSONDecoder): + objs = json.loads(j, cls=decoder_class) if not isinstance(objs, list): raise TypeError("The root element of the JSON provided was not a JSON array. Perhaps you intended to use `.from_json(j)` instead?") From 477b9a925ebd509dfc05e06d341e902b9516b324 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 16 Jun 2026 08:57:09 +0100 Subject: [PATCH 36/50] minor edits --- Python_Engine/Python/src/python_toolkit/bhom/analytics.py | 2 +- Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index a39b66c3..7ba81cb9 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -154,7 +154,7 @@ def decorator(function: Callable): @wraps(function) def wrapper(*args, **kwargs) -> Any: """A wrapper around the function that captures usage analytics.""" - + if disable: CONSOLE_LOGGER.debug("bhom_analytics is curently disabled.") return function(*args, **kwargs) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 61d564dd..c09f76d6 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -86,7 +86,6 @@ def deserialise_unknown(self, obj: Union['IObject', 'BHoMObject', dict]): """Override this method in a subclass to add extra object hook on top of the existing method. If the object is an object serialised by the BHoM serialiser, the output object will be a BHoMObject or IObject, so use isinstance() to select.""" return obj - class BHoMJSONEncoder(JSONEncoder): def default(self, o): if isinstance(o, BHoMObject): @@ -158,7 +157,6 @@ def serialise_unknown(self, obj: Any) -> Union[Dict[str, str], str, List[str], A """ return super(type(self), self).default(obj) #fallback to default json decoder (ValueError) if object is not a BHoMObject or common serialisable type. - class IObject: """More generic version of BHoMObject, for non-native objects serialised by the BHoM serialiser, but do not inherit from BHoMObject.""" _t: str From 8f273c0a777dcd68726235ab35b70e7a8d946e43 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 2 Jul 2026 16:03:55 +0100 Subject: [PATCH 37/50] Add decorator for bhom callable methods, unfinished --- .../bhom/decorators/__init__.py | 1 + .../decorators/bhom_callable_decorator.py | 65 +++++++++++++++++++ Python_Engine/Python_Engine.csproj | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py new file mode 100644 index 00000000..cbb34ffe --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py @@ -0,0 +1 @@ +from .bhom_callable_decorator import bhom_callable \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py new file mode 100644 index 00000000..7e167077 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py @@ -0,0 +1,65 @@ +import json +from typing import Any, Callable, Union, Dict +from functools import wraps +from ..bhom_object import CONSOLE_LOGGER, BHoMJSONDecoder, BHoMJSONEncoder, BHoMObject + +def bhom_callable(argument_types:Dict[str, type] = {}, encoder_cls: type = BHoMJSONEncoder, decoder_cls: type = BHoMJSONDecoder): + """Decorator for functions to be made callable from BHoM C# methods/adapters. + + Note: methods that this wraps must not have "__input_json__" as a default kwarg, as this is used internally to allow BHoM adapters to call the method. + + when __input_json__ is set, this will cause the method to always output BHoM style json (using the encoder_cls provided to this decorator) + + Args: + argument_types (dict[str, type]): this is a dictionary that is used to map the argument names to types (specifically BHoMObject types) to subclasses of BHoMObjects. + For example, if you have a class that is a subclass of BHoMObject, the default serialiser will only deserialise json to a BHoMObject. + To go the extra step to get your class, you must provide the type in this dictionary to allow the wrapper to convert the BHoMObject type to your desired type. + + encoder_cls (JSONEncoder): A JSONEncoder (ideally one that is a subclass of BHoMJSONEncoder). Mainly this is for if a custom encoder has been implemented for a specific toolkit. + + decoder_cls (JSONDecoder): same as encoder_cls but for JSONDecoder. + """ + def decorator(function: Callable): + + @wraps(function) + def wrapper(*args, **kwargs) -> Union[str, Any]: + + do_wrap:bool = False + + if "__input_json__" in kwargs and len(args) == 0: + do_wrap = True + + #get dictionary from input as file path or json like string. + input_json = kwargs.pop("__input_json__") + + if not input_json.startswith("{"): #assume it's a path + with open(input_json, "r") as f: + input_json = f.read() + + try: + json_kwargs: dict = json.loads(input_json, cls=decoder_cls) + except: + CONSOLE_LOGGER.error("Could not load JSON from file or string due to invalid JSON. Attempting to run with given args and kwargs.", exc_info=1) + + #update kwargs with json + for kwarg_name in json_kwargs: + val = json_kwargs[kwarg_name] + + if kwarg_name in argument_types: + t = argument_types[kwarg_name] + + if issubclass(t, BHoMObject) and type(json_kwargs[kwarg_name]) is BHoMObject: + val = t._from_bhom_object(json_kwargs[kwarg_name]) + + kwargs[kwarg_name] = val + + rtn = function(*args, **kwargs) + + if do_wrap: + json_rtn = json.dumps(rtn, cls=encoder_cls) + + return json_rtn + + return rtn + return wrapper + return decorator \ No newline at end of file diff --git a/Python_Engine/Python_Engine.csproj b/Python_Engine/Python_Engine.csproj index 429318f6..d10626af 100644 --- a/Python_Engine/Python_Engine.csproj +++ b/Python_Engine/Python_Engine.csproj @@ -39,6 +39,6 @@ - + From 227c5fd392cf88325597b166192294130b4b03ac Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Fri, 31 Jul 2026 13:26:00 +0100 Subject: [PATCH 38/50] change decorator to also record which methods have been decorated (which are only accessible if the file contained is imported) --- .../bhom/decorators/__init__.py | 2 +- .../decorators/_bhom_callable_decorator.py | 83 +++++++++++++++++++ .../decorators/bhom_callable_decorator.py | 65 --------------- .../src/python_toolkit/bhom/run_wrapped.py | 7 ++ .../python_toolkit/bhom/wrapped/__init__.py | 13 +++ .../bhom/wrapped/_test_wrapped.py | 10 +++ 6 files changed, 114 insertions(+), 66 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py delete mode 100644 Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py index cbb34ffe..70cea52d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/__init__.py @@ -1 +1 @@ -from .bhom_callable_decorator import bhom_callable \ No newline at end of file +from ._bhom_callable_decorator import bhom_wrapper \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py new file mode 100644 index 00000000..04e9963b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py @@ -0,0 +1,83 @@ +import json +from typing import Any, Callable, Union, Dict +from functools import wraps +from ..bhom_object import CONSOLE_LOGGER, BHoMJSONDecoder, BHoMJSONEncoder, BHoMObject + +class _BHoMWrapper: + _registered_methods: Dict[str, Callable] = {} + + def bhom_callable(self, identifier: str, argument_types:Dict[str, type] = {}, encoder_cls: type = BHoMJSONEncoder, decoder_cls: type = BHoMJSONDecoder): + """Decorator for functions to be made callable from BHoM C# methods/adapters. + + Note: methods that this wraps must not have "__input_json__" as a default kwarg, as this is used internally to allow BHoM adapters to call the method. + + when __input_json__ is set, this will cause the method to always output BHoM style json (using the encoder_cls provided to this decorator) + + Args: + argument_types (dict[str, type]): this is a dictionary that is used to map the argument names to types (specifically BHoMObject types) to subclasses of BHoMObjects. + For example, if you have a class that is a subclass of BHoMObject, the default serialiser will only deserialise json to a BHoMObject. + To go the extra step to get your class, you must provide the type in this dictionary to allow the wrapper to convert the BHoMObject type to your desired type. + + encoder_cls (JSONEncoder): A JSONEncoder (ideally one that is a subclass of BHoMJSONEncoder). Mainly this is for if a custom encoder has been implemented for a specific toolkit. + + decoder_cls (JSONDecoder): same as encoder_cls but for JSONDecoder. + """ + def decorator(function: Callable): + + @wraps(function) + def wrapper(*args, **kwargs) -> Union[str, Any]: + + do_wrap:bool = False + + if "__input_json__" in kwargs and len(args) == 0: + do_wrap = True + + #get dictionary from input as file path or json like string. + input_json = kwargs.pop("__input_json__") + + if not input_json.startswith("{"): #assume it's a path + with open(input_json, "r") as f: + input_json = f.read() + + try: + json_kwargs: dict = json.loads(input_json, cls=decoder_cls) + except: + CONSOLE_LOGGER.error("Could not load JSON from file or string due to invalid JSON. Attempting to run with given args and kwargs.", exc_info=1) + + #update kwargs with json + for kwarg_name in json_kwargs: + val = json_kwargs[kwarg_name] + + if kwarg_name in argument_types: + t = argument_types[kwarg_name] + + if issubclass(t, BHoMObject) and type(json_kwargs[kwarg_name]) is BHoMObject: + val = t._from_bhom_object(json_kwargs[kwarg_name]) + + kwargs[kwarg_name] = val + + rtn = function(*args, **kwargs) + + if do_wrap: + json_rtn = json.dumps(rtn, cls=encoder_cls) + + return json_rtn + + return rtn + + self._registered_methods[identifier] = wrapper + return wrapper + + return decorator + + def get_registered_method(self, method_identifier: str): + method = self._registered_methods.get(method_identifier, None) + + if method is None: + CONSOLE_LOGGER.error("The requested method could not be found.") + + return method + +#the registered methods are stored as a class attribute, so this isn't actually needed +#but this is easier to use, otherwise a new instance must be created every time a method needs to be wrapped +bhom_wrapper = _BHoMWrapper() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py deleted file mode 100644 index 7e167077..00000000 --- a/Python_Engine/Python/src/python_toolkit/bhom/decorators/bhom_callable_decorator.py +++ /dev/null @@ -1,65 +0,0 @@ -import json -from typing import Any, Callable, Union, Dict -from functools import wraps -from ..bhom_object import CONSOLE_LOGGER, BHoMJSONDecoder, BHoMJSONEncoder, BHoMObject - -def bhom_callable(argument_types:Dict[str, type] = {}, encoder_cls: type = BHoMJSONEncoder, decoder_cls: type = BHoMJSONDecoder): - """Decorator for functions to be made callable from BHoM C# methods/adapters. - - Note: methods that this wraps must not have "__input_json__" as a default kwarg, as this is used internally to allow BHoM adapters to call the method. - - when __input_json__ is set, this will cause the method to always output BHoM style json (using the encoder_cls provided to this decorator) - - Args: - argument_types (dict[str, type]): this is a dictionary that is used to map the argument names to types (specifically BHoMObject types) to subclasses of BHoMObjects. - For example, if you have a class that is a subclass of BHoMObject, the default serialiser will only deserialise json to a BHoMObject. - To go the extra step to get your class, you must provide the type in this dictionary to allow the wrapper to convert the BHoMObject type to your desired type. - - encoder_cls (JSONEncoder): A JSONEncoder (ideally one that is a subclass of BHoMJSONEncoder). Mainly this is for if a custom encoder has been implemented for a specific toolkit. - - decoder_cls (JSONDecoder): same as encoder_cls but for JSONDecoder. - """ - def decorator(function: Callable): - - @wraps(function) - def wrapper(*args, **kwargs) -> Union[str, Any]: - - do_wrap:bool = False - - if "__input_json__" in kwargs and len(args) == 0: - do_wrap = True - - #get dictionary from input as file path or json like string. - input_json = kwargs.pop("__input_json__") - - if not input_json.startswith("{"): #assume it's a path - with open(input_json, "r") as f: - input_json = f.read() - - try: - json_kwargs: dict = json.loads(input_json, cls=decoder_cls) - except: - CONSOLE_LOGGER.error("Could not load JSON from file or string due to invalid JSON. Attempting to run with given args and kwargs.", exc_info=1) - - #update kwargs with json - for kwarg_name in json_kwargs: - val = json_kwargs[kwarg_name] - - if kwarg_name in argument_types: - t = argument_types[kwarg_name] - - if issubclass(t, BHoMObject) and type(json_kwargs[kwarg_name]) is BHoMObject: - val = t._from_bhom_object(json_kwargs[kwarg_name]) - - kwargs[kwarg_name] = val - - rtn = function(*args, **kwargs) - - if do_wrap: - json_rtn = json.dumps(rtn, cls=encoder_cls) - - return json_rtn - - return rtn - return wrapper - return decorator \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py b/Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py new file mode 100644 index 00000000..2a21c362 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/run_wrapped.py @@ -0,0 +1,7 @@ +from .decorators import bhom_wrapper +from . import wrapped + +def run_wrapped(identifier: str, json: str): + method = bhom_wrapper.get_registered_method(identifier) + + print(method(__input_json__ = json)) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py new file mode 100644 index 00000000..10636f63 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/__init__.py @@ -0,0 +1,13 @@ +from importlib import import_module +from pathlib import Path +import os + +python_files = Path(__file__).parent.glob("**/*.py") + +for file in python_files: + if file.name == "__init__.py": + continue + + rel = file.relative_to(Path(__file__).parent) + module = "." + str(rel).replace(".py", "").replace(os.path.sep, ".") + import_module(module, "python_toolkit.bhom.wrapped") \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py new file mode 100644 index 00000000..ac0764f1 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/wrapped/_test_wrapped.py @@ -0,0 +1,10 @@ +from python_toolkit.plot.heatmap import heatmap +from ..decorators import bhom_wrapper +import pandas as pd + +@bhom_wrapper.bhom_callable("test") +def heatmap_2(geometry, **kwargs): + print(geometry) + print(kwargs["arg2"]) + geometry.x = 20 + return geometry From 9c1984257dfac1be902dbc2f6216c42d3df76539 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Wed, 12 Aug 2026 16:29:11 +0100 Subject: [PATCH 39/50] Apply suggestion from @Tom-Kingstone --- .../bhom/decorators/_bhom_callable_decorator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py index 04e9963b..9fc9227e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py @@ -40,7 +40,9 @@ def wrapper(*args, **kwargs) -> Union[str, Any]: input_json = f.read() try: - json_kwargs: dict = json.loads(input_json, cls=decoder_cls) + json_kwargs: Union[dict, IObject] = json.loads(input_json, cls=decoder_cls) + if isinstance(json_kwargs, IObject): + json_kwargs = json_kwargs.to_dict() except: CONSOLE_LOGGER.error("Could not load JSON from file or string due to invalid JSON. Attempting to run with given args and kwargs.", exc_info=1) From de03cc96e92c9c81a809ff8ca0cc873aa1bc7450 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 18 Aug 2026 14:23:44 +0100 Subject: [PATCH 40/50] throw error if a command can't be found instead of returning None --- .../python_toolkit/bhom/decorators/_bhom_callable_decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py index 9fc9227e..b1263ffa 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py @@ -76,7 +76,7 @@ def get_registered_method(self, method_identifier: str): method = self._registered_methods.get(method_identifier, None) if method is None: - CONSOLE_LOGGER.error("The requested method could not be found.") + raise NotImplementedError(f"The requested method {method_identifier} is not implemented or could not be found.") return method From 3ce420d9f3feac76584d20fe01e0ebc500e52404 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Tue, 18 Aug 2026 14:41:15 +0100 Subject: [PATCH 41/50] fix date serialisation to/from bhom --- .../Python/src/python_toolkit/bhom/bhom_object.py | 10 +++++++++- .../bhom/decorators/_bhom_callable_decorator.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index c09f76d6..6cedfcb8 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -1,6 +1,7 @@ from base64 import decode import copy from ctypes import ArgumentError +from datetime import datetime from json import encoder from pathlib import Path import uuid @@ -10,6 +11,7 @@ from json import JSONEncoder, JSONDecoder from .logging import CONSOLE_LOGGER from . import BHOM_VERSION +from .util import bson_unix_ticks, bson_unix_ticks_to_datetime import pandas as pd import numpy as np @@ -45,6 +47,10 @@ def __init__(self, *args, **kwargs): json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs) def object_hook(self, d): + if "$date" in d: + CONSOLE_LOGGER.debug(f"deserialising timestamp {d}") + return bson_unix_ticks_to_datetime(d["$date"]) + if "_t" not in d: CONSOLE_LOGGER.debug(f"BHoMJSONDecoder could not convert the following dictionary into a BHoMObject due to a missing '_t' property. Falling back to dictionary: {d}") return self.deserialise_unknown(d) @@ -140,7 +146,9 @@ def default(self, o): elif isinstance(o, np.ndarray): return o.tolist() elif isinstance(o, pd.Timestamp): - return o.isoformat() + return {"$date": bson_unix_ticks(o.to_pydatetime(), True)} + elif isinstance(o, datetime): + return {"$date": bson_unix_ticks(o, True)} elif isinstance(o, pd.Series): return dict(zip(o.index.astype(str), o)) elif isinstance(o, Path): diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py index b1263ffa..7e206387 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py @@ -1,7 +1,7 @@ import json from typing import Any, Callable, Union, Dict from functools import wraps -from ..bhom_object import CONSOLE_LOGGER, BHoMJSONDecoder, BHoMJSONEncoder, BHoMObject +from ..bhom_object import CONSOLE_LOGGER, BHoMJSONDecoder, BHoMJSONEncoder, BHoMObject, IObject class _BHoMWrapper: _registered_methods: Dict[str, Callable] = {} From eaddefebc945e36890c60f3a067b5548c86bc0a2 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Mon, 24 Aug 2026 17:17:55 +0100 Subject: [PATCH 42/50] fix double adding segments for camel case conversion, and make decorator not convert strings if they are the direct return from a method --- Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py | 1 + .../bhom/decorators/_bhom_callable_decorator.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py index 6cedfcb8..10785de5 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/bhom_object.py @@ -37,6 +37,7 @@ def convert_camel_to_pascal(s: str): continue elif sec.startswith("_"): parts.append(sec) + continue parts.append(sec.capitalize()) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py index 7e206387..436ebcb9 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/decorators/_bhom_callable_decorator.py @@ -61,6 +61,10 @@ def wrapper(*args, **kwargs) -> Union[str, Any]: rtn = function(*args, **kwargs) if do_wrap: + #don't bother serialising if the method already returns a string. + if isinstance(rtn, str): + return rtn + json_rtn = json.dumps(rtn, cls=encoder_cls) return json_rtn From 941979cf1a52b9edd1bd2d69de37777c3ee7575a Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 3 Sep 2026 14:51:33 +0100 Subject: [PATCH 43/50] update dockerfile to include aspdotnet and BHoM assemblies folder --- Python_Engine/Python/Dockerfile | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Python_Engine/Python/Dockerfile b/Python_Engine/Python/Dockerfile index 349adb60..dde75b24 100644 --- a/Python_Engine/Python/Dockerfile +++ b/Python_Engine/Python/Dockerfile @@ -1,7 +1,22 @@ FROM python:3.10-slim - COPY . /Python_Toolkit +#requires build argument --build-context bhom-assemblies C:/ProgramData/BHoM/Assemblies +COPY --from=bhom-assemblies . /bin/BHoM/Assemblies + +#install .net core 8.0, and set the pythonnet runtime to the .net core CLR +RUN apt-get update \ + && apt-get install -y wget + +RUN wget https://packages.microsoft.com/config/debian/13/packages-microsoft-prod.deb -O packages-microsoft-prod.deb +RUN dpkg -i packages-microsoft-prod.deb +RUN rm packages-microsoft-prod.deb + +RUN apt-get update \ + && apt-get install -y aspnetcore-runtime-10.0 + +ENV PYTHONNET_RUNTIME="coreclr" + RUN pip3 install ./Python_Toolkit RUN rm ./Python_Toolkit -rf \ No newline at end of file From 8fbed44ea85160ba569001e9193db6f48009668d Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 9 Sep 2026 11:40:43 +0100 Subject: [PATCH 44/50] add UpdateBHoMPackages method --- Python_Engine/Compute/UpdateBHoMPackages.cs | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Python_Engine/Compute/UpdateBHoMPackages.cs diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs new file mode 100644 index 00000000..b8a8a184 --- /dev/null +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -0,0 +1,65 @@ +using BH.oM.Python; +using BH.oM.Python.Enums; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Text; + +namespace BH.Engine.Python +{ + public static partial class Compute + { + public static void UpdateBHoMPackages(this string environmentName, PythonVersion version = PythonVersion.Undefined) + { + //construct the expected PythonEnvironment and pass to UpdateBHoMPackages + PythonEnvironment env = new PythonEnvironment() + { + Name = environmentName, + }; + + //python version is only used if the environment name is this toolkits name. + if (environmentName == Query.ToolkitName()) + { + if (version == PythonVersion.Undefined) + { + BH.Engine.Base.Compute.RecordError("The version must be specified when updating BHoM packages for a base python environment. No updates have occurred."); + return; + } + + env.Executable = Path.Combine(Query.DirectoryBaseEnvironment(version), "python.exe"); + } + else + env.Executable = Query.VirtualEnvironmentExecutable(environmentName); + + UpdateBHoMPackages(env); + } + + [Description("Direct method to update BHoM Python editable packages using the PythonEnvironment directly.")] + public static void UpdateBHoMPackages(this PythonEnvironment environment) + { + if (!Query.VirtualEnvironmentExists(environment.Name)) + { + BH.Engine.Base.Compute.RecordError("Given environment does not exist."); + return; + } + + string localPackageDirectory = ResolvePackageDirectory(environment); + + if (!Directory.Exists(localPackageDirectory)) + { + BH.Engine.Base.Compute.RecordError($"There is no local package directory for {environment.Name} (searched at \"{localPackageDirectory}\"). No packages were updated."); + return; + } + + InstallPackageLocal(environment, localPackageDirectory); + } + + private static string ResolvePackageDirectory(PythonEnvironment environment) + { + string packageName = environment.Name; + string codeDirectory = Query.DirectoryCode(); + return Path.Combine(codeDirectory, packageName); + } + } +} \ No newline at end of file From f201b603caa680daff8cc3d014e6bda0a2956278 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 9 Sep 2026 11:45:42 +0100 Subject: [PATCH 45/50] add documentation attributes --- Python_Engine/Compute/UpdateBHoMPackages.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs index b8a8a184..1814a07a 100644 --- a/Python_Engine/Compute/UpdateBHoMPackages.cs +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -1,4 +1,5 @@ -using BH.oM.Python; +using BH.oM.Base.Attributes; +using BH.oM.Python; using BH.oM.Python.Enums; using System; using System.Collections.Generic; @@ -10,6 +11,9 @@ namespace BH.Engine.Python { public static partial class Compute { + [Description("Using the environment name (and version if the environment name is the base python environment), directly install/update missing BHoM packages.")] + [Input("environmentName", "The name of the environment to update.")] + [Input("version", "If the environment name is the name of the base python environment, the version that needs updating.")] public static void UpdateBHoMPackages(this string environmentName, PythonVersion version = PythonVersion.Undefined) { //construct the expected PythonEnvironment and pass to UpdateBHoMPackages @@ -35,7 +39,10 @@ public static void UpdateBHoMPackages(this string environmentName, PythonVersion UpdateBHoMPackages(env); } - [Description("Direct method to update BHoM Python editable packages using the PythonEnvironment directly.")] + /***************************************************/ + + [Description("Using a PythonEnvironment, directly install/update missing BHoM packages.")] + [Input("environment", "The python environment to update.")] public static void UpdateBHoMPackages(this PythonEnvironment environment) { if (!Query.VirtualEnvironmentExists(environment.Name)) @@ -55,6 +62,8 @@ public static void UpdateBHoMPackages(this PythonEnvironment environment) InstallPackageLocal(environment, localPackageDirectory); } + /***************************************************/ + private static string ResolvePackageDirectory(PythonEnvironment environment) { string packageName = environment.Name; From 2e207eb88f1a8445d31ad0fbb727e890f36d85b9 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 9 Sep 2026 12:05:05 +0100 Subject: [PATCH 46/50] copyright compliance --- Python_Engine/Compute/UpdateBHoMPackages.cs | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs index 1814a07a..c4b58e47 100644 --- a/Python_Engine/Compute/UpdateBHoMPackages.cs +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -1,4 +1,26 @@ -using BH.oM.Base.Attributes; +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + +using BH.oM.Base.Attributes; using BH.oM.Python; using BH.oM.Python.Enums; using System; From 607e4bd90d87ece6990a2b1952c9e2c7d499f605 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 9 Sep 2026 12:28:29 +0100 Subject: [PATCH 47/50] check executable directly instead. --- Python_Engine/Compute/UpdateBHoMPackages.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs index c4b58e47..2c2577dc 100644 --- a/Python_Engine/Compute/UpdateBHoMPackages.cs +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -67,9 +67,9 @@ public static void UpdateBHoMPackages(this string environmentName, PythonVersion [Input("environment", "The python environment to update.")] public static void UpdateBHoMPackages(this PythonEnvironment environment) { - if (!Query.VirtualEnvironmentExists(environment.Name)) + if (!File.Exists(environment.Executable)) { - BH.Engine.Base.Compute.RecordError("Given environment does not exist."); + BH.Engine.Base.Compute.RecordError($"Given environment or base install {environment.Executable} does not exist."); return; } From 8d38f59c107c2b596113f35c46ae36f71a56fe65 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Wed, 9 Sep 2026 15:33:14 +0100 Subject: [PATCH 48/50] use pip based check for dependencies to be updated --- Python_Engine/Compute/UpdateBHoMPackages.cs | 38 ++++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs index 2c2577dc..0b411ec1 100644 --- a/Python_Engine/Compute/UpdateBHoMPackages.cs +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -20,13 +20,16 @@ * along with this code. If not, see . */ +using BH.oM.Base; using BH.oM.Base.Attributes; using BH.oM.Python; using BH.oM.Python.Enums; using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; namespace BH.Engine.Python @@ -73,24 +76,33 @@ public static void UpdateBHoMPackages(this PythonEnvironment environment) return; } - string localPackageDirectory = ResolvePackageDirectory(environment); + //`python -m pip list -e --format json` lists all environment packages that are editable installs. + //As all BHoM packages are editable installs, this handily lists all the packages that should be updated if necessary. + //side effect of updating other editable installs if they have changed, but I suspect this won't be a problem as anyone who has these would update packages manually instead. + System.Diagnostics.Process process = new System.Diagnostics.Process() + { + StartInfo = new System.Diagnostics.ProcessStartInfo() + { + FileName = environment.Executable, + Arguments = $"-m pip list -e --format json", + UseShellExecute = false, + RedirectStandardError = true, + } + }; + + process.StartInfo.Environment["PYTHONHOME"] = ""; + string stdOut; - if (!Directory.Exists(localPackageDirectory)) + using (Process p = Process.Start(process.StartInfo)) { - BH.Engine.Base.Compute.RecordError($"There is no local package directory for {environment.Name} (searched at \"{localPackageDirectory}\"). No packages were updated."); - return; + stdOut = p.StandardOutput.ReadToEnd(); + p.WaitForExit(); } - InstallPackageLocal(environment, localPackageDirectory); - } + IEnumerable objs = Serialiser.Convert.FromJsonArray(stdOut).OfType(); - /***************************************************/ - - private static string ResolvePackageDirectory(PythonEnvironment environment) - { - string packageName = environment.Name; - string codeDirectory = Query.DirectoryCode(); - return Path.Combine(codeDirectory, packageName); + foreach (CustomObject obj in objs) + InstallPackageLocal(environment, (string)obj.CustomData["editable_project_location"]); } } } \ No newline at end of file From 81699b29e576c76c09a64d2f032d1e870f9b7ef4 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 10 Sep 2026 11:44:31 +0100 Subject: [PATCH 49/50] ensure that when getting environments, they are fully updated. --- Python_Engine/Compute/BasePythonEnvironment.cs | 6 +++++- Python_Engine/Compute/UpdateBHoMPackages.cs | 2 +- Python_Engine/Compute/VirtualEnvironment.cs | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Python_Engine/Compute/BasePythonEnvironment.cs b/Python_Engine/Compute/BasePythonEnvironment.cs index fa90129e..8ec03656 100644 --- a/Python_Engine/Compute/BasePythonEnvironment.cs +++ b/Python_Engine/Compute/BasePythonEnvironment.cs @@ -60,7 +60,11 @@ public static PythonEnvironment BasePythonEnvironment( bool exists = File.Exists(targetExecutable); if (exists && reload) - return new PythonEnvironment() { Name = Query.ToolkitName(), Executable = targetExecutable }; + { + PythonEnvironment env = new PythonEnvironment() { Name = Query.ToolkitName(), Executable = targetExecutable }; + UpdateBHoMPackages(env); + return env; + } if (exists && !reload) // remove all existing environments and kernels diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs index 0b411ec1..e1133531 100644 --- a/Python_Engine/Compute/UpdateBHoMPackages.cs +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -86,7 +86,7 @@ public static void UpdateBHoMPackages(this PythonEnvironment environment) FileName = environment.Executable, Arguments = $"-m pip list -e --format json", UseShellExecute = false, - RedirectStandardError = true, + RedirectStandardOutput = true, } }; diff --git a/Python_Engine/Compute/VirtualEnvironment.cs b/Python_Engine/Compute/VirtualEnvironment.cs index 1e02dad5..6ed9dde7 100644 --- a/Python_Engine/Compute/VirtualEnvironment.cs +++ b/Python_Engine/Compute/VirtualEnvironment.cs @@ -60,7 +60,11 @@ public static PythonEnvironment VirtualEnvironment(this PythonVersion version, s bool exists = Query.VirtualEnvironmentExists(name); if (exists && reload) - return new PythonEnvironment() { Name = name, Executable = targetExecutable }; + { + PythonEnvironment env = new PythonEnvironment() { Name = name, Executable = targetExecutable }; + UpdateBHoMPackages(env); + return env; + } if (exists && !reload) { From 41c39a853359e86912fb31c3cb48397bc129ddb5 Mon Sep 17 00:00:00 2001 From: Tom Kingstone Date: Thu, 10 Sep 2026 12:59:03 +0100 Subject: [PATCH 50/50] remove carriage return from stdout before reading it as a json array --- Python_Engine/Compute/UpdateBHoMPackages.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Python_Engine/Compute/UpdateBHoMPackages.cs b/Python_Engine/Compute/UpdateBHoMPackages.cs index e1133531..c1f3024f 100644 --- a/Python_Engine/Compute/UpdateBHoMPackages.cs +++ b/Python_Engine/Compute/UpdateBHoMPackages.cs @@ -99,6 +99,8 @@ public static void UpdateBHoMPackages(this PythonEnvironment environment) p.WaitForExit(); } + stdOut = stdOut.TrimEnd('\r', '\n'); + IEnumerable objs = Serialiser.Convert.FromJsonArray(stdOut).OfType(); foreach (CustomObject obj in objs)