From fc946951cdb7edee880c07d043008bf95eb4857b Mon Sep 17 00:00:00 2001 From: Nathan <95725385+treefern@users.noreply.github.com> Date: Fri, 16 Aug 2024 11:04:51 +0000 Subject: [PATCH 1/6] NPI-3458 introduced SolutionTypes utility class and supporting definitions --- gnssanalysis/solution_types.py | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 gnssanalysis/solution_types.py diff --git a/gnssanalysis/solution_types.py b/gnssanalysis/solution_types.py new file mode 100644 index 0000000..db5e54d --- /dev/null +++ b/gnssanalysis/solution_types.py @@ -0,0 +1,51 @@ +class SolutionType: + _name: str + _long_name: str + + def __init__(self, name: str, long_name: str) -> None: + self._name = name + self._long_name = long_name + + @property + def name(self): + return self._name + + @property + def long_name(self): + return self._long_name + + def __str__(self) -> str: + return self._name + + def __repr__(self) -> str: + return self._name + + +class SolutionTypes: + """ + Defines valid solution type identifiers specified for use in the IGS long product filename convention v2: + https://files.igs.org/pub/resource/guidelines/Guidelines_For_Long_Product_Filenames_in_the_IGS_v2.0.pdf + """ + + FIN = SolutionType("FIN", "final") # Final products + NRT = SolutionType("NRT", "near-real time") # Near-Real Time (between ULT and RTS) + PRD = SolutionType("PRD", "predicted") # Predicted products + RAP = SolutionType("RAP", "rapid") # Rapid products + RTS = SolutionType("RTS", "real-time streamed") # Real-Time streamed products + SNX = SolutionType("SNX", "sinex combination") # SINEX Combination product + ULT = SolutionType("ULT", "ultra-rapid") # Ultra-rapid products (every 6 hours) + + # To support search function below + _all: list[SolutionType] = [FIN, NRT, PRD, RAP, RTS, SNX, ULT] + + @staticmethod + def from_name(name: str): + """ + Returns the relevant static SolutionType object, given the solution type's short name. + :param str name: The short name of the solution type e.g. 'RAP', 'ULT', 'FIN', 'SNX' + """ + name = name.upper() + for solution_type in SolutionTypes._all: + if name == solution_type.name: + return solution_type + raise ValueError(f"No known solution type with short name '{name}'") From 0f2341e397d500f7f327667d7e0154556c496843 Mon Sep 17 00:00:00 2001 From: Nathan <95725385+treefern@users.noreply.github.com> Date: Tue, 20 Aug 2024 05:03:00 +0000 Subject: [PATCH 2/6] NPI-3458 added equality check to solution types based just on short name --- gnssanalysis/solution_types.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gnssanalysis/solution_types.py b/gnssanalysis/solution_types.py index db5e54d..de28681 100644 --- a/gnssanalysis/solution_types.py +++ b/gnssanalysis/solution_types.py @@ -20,6 +20,16 @@ def __str__(self) -> str: def __repr__(self) -> str: return self._name + def __eq__(self, other): + """ + Override default equality check + """ + if not isinstance(other, SolutionType): + return False + return self._name == other._name + # Note that in Python, there is both an equality and an inequality check. + # But in Python 3 the inequality check leverages 'not __eq__()' by default. + class SolutionTypes: """ From 3fd6ed9d900f9d788992155bd2345214e813212b Mon Sep 17 00:00:00 2001 From: Nathan <95725385+treefern@users.noreply.github.com> Date: Tue, 20 Aug 2024 07:22:53 +0000 Subject: [PATCH 3/6] NPI-3458 extend SolutionTypes to better handle string representation of unknown solution type --- gnssanalysis/solution_types.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/gnssanalysis/solution_types.py b/gnssanalysis/solution_types.py index de28681..4d489fa 100644 --- a/gnssanalysis/solution_types.py +++ b/gnssanalysis/solution_types.py @@ -1,3 +1,8 @@ +import logging +from typing import Optional + +logging.basicConfig(format="%(asctime)s [%(funcName)s] %(levelname)s: %(message)s") + class SolutionType: _name: str _long_name: str @@ -24,6 +29,10 @@ def __eq__(self, other): """ Override default equality check """ + # If we're the unknown shorthand "UNK", consider None equivalent. Both are expressions of unknown solution type + if self._name == "UNK" and other is None: + return True + if not isinstance(other, SolutionType): return False return self._name == other._name @@ -45,15 +54,23 @@ class SolutionTypes: SNX = SolutionType("SNX", "sinex combination") # SINEX Combination product ULT = SolutionType("ULT", "ultra-rapid") # Ultra-rapid products (every 6 hours) + # Internal representation of unknown, for contexts where defaults are passed as strings. + UNK = SolutionType("UNK", "unknown solution type") + # To support search function below - _all: list[SolutionType] = [FIN, NRT, PRD, RAP, RTS, SNX, ULT] + _all: list[SolutionType] = [FIN, NRT, PRD, RAP, RTS, SNX, ULT, UNK] @staticmethod - def from_name(name: str): + def from_name(name: Optional[str]): """ - Returns the relevant static SolutionType object, given the solution type's short name. - :param str name: The short name of the solution type e.g. 'RAP', 'ULT', 'FIN', 'SNX' + Returns the relevant static SolutionType object, given the solution type's short name (case insensitive). + :param str name: The short name of the solution type e.g. 'RAP', 'ULT', 'FIN', 'SNX'. Also accepts unknwon, as + either UNK or None """ + if name is None: # None is analogous to UNK + logging.debug("Converted solution type value of None, to SolutionTypes.UNK") + return SolutionTypes.UNK + name = name.upper() for solution_type in SolutionTypes._all: if name == solution_type.name: From f6ecd136174041864c3a4616c1e147149bff6601 Mon Sep 17 00:00:00 2001 From: Nathan <95725385+treefern@users.noreply.github.com> Date: Mon, 2 Sep 2024 06:34:20 +0000 Subject: [PATCH 4/6] NPI-3458 further refinement of SolutionType classes to operate statically and protect against modification of class attributes --- gnssanalysis/enum_meta_properties.py | 16 +++ gnssanalysis/solution_types.py | 140 ++++++++++++++++++--------- 2 files changed, 109 insertions(+), 47 deletions(-) create mode 100644 gnssanalysis/enum_meta_properties.py diff --git a/gnssanalysis/enum_meta_properties.py b/gnssanalysis/enum_meta_properties.py new file mode 100644 index 0000000..84e424a --- /dev/null +++ b/gnssanalysis/enum_meta_properties.py @@ -0,0 +1,16 @@ +class EnumMetaProperties(type): + """ + This metaclass: + - intercepts attempts to set *class* attributes, and rejects them. + - NOTE: In the class or abstract class using this, you should also define an __init__() which raises + an exception, to prevent instantiation. + - defines the class string representation as being *just* the class name, without any fluff. + + Loosely based on carefully reviewed AI generated examples from Microsoft Copilot. + """ + + def __setattr__(cls, name: str, value) -> None: + raise AttributeError(f"Attributes of {cls} act as constants. Do not modify them.") + + def __repr__(cls) -> str: + return f"{cls.__name__}" diff --git a/gnssanalysis/solution_types.py b/gnssanalysis/solution_types.py index 4d489fa..7d7987c 100644 --- a/gnssanalysis/solution_types.py +++ b/gnssanalysis/solution_types.py @@ -1,76 +1,122 @@ import logging -from typing import Optional +from gnssanalysis.enum_meta_properties import EnumMetaProperties logging.basicConfig(format="%(asctime)s [%(funcName)s] %(levelname)s: %(message)s") -class SolutionType: - _name: str - _long_name: str - def __init__(self, name: str, long_name: str) -> None: - self._name = name - self._long_name = long_name +# Abstract base class. Leverages above Immutable metaclass to prevent its (effectively) constants, from being modified. +class SolutionType(metaclass=EnumMetaProperties): + name: str + long_name: str - @property - def name(self): - return self._name + def __init__(self): + raise Exception("This is intended to act akin to an enum. Don't instantiate it.") - @property - def long_name(self): - return self._long_name - def __str__(self) -> str: - return self._name +class FIN(SolutionType): + """ + Final products + """ - def __repr__(self) -> str: - return self._name + name = "FIN" + long_name = "final" - def __eq__(self, other): - """ - Override default equality check - """ - # If we're the unknown shorthand "UNK", consider None equivalent. Both are expressions of unknown solution type - if self._name == "UNK" and other is None: - return True - if not isinstance(other, SolutionType): - return False - return self._name == other._name - # Note that in Python, there is both an equality and an inequality check. - # But in Python 3 the inequality check leverages 'not __eq__()' by default. +class NRT(SolutionType): + """ + Near-Real Time (between ULT and RTS) + """ + + name = "PRD" + long_name = "near-real time" + + +class PRD(SolutionType): + """ + Predicted products + """ + + name = "PRD" + long_name = "predicted" + + +class RAP(SolutionType): + """ + Rapid products + """ + + name = "RAP" + long_name = "rapid" + + +class RTS(SolutionType): + """ + Real-Time streamed products + """ + + name = "RTS" + long_name = "real-time streamed" + + +class SNX(SolutionType): + """ + SINEX Combination product + """ + + name = "SNX" + long_name = "sinex combination" + + +class ULT(SolutionType): + """ + Ultra-rapid products + The only orbit product from IGS which isn't a 1 day span + """ + + name = "ULT" + long_name = "ultra-rapid" + + +class UNK(SolutionType): + """ + Internal representation of an unknown solution type. + """ + + name = "UNK" + long_name = "unknown solution type" class SolutionTypes: """ Defines valid solution type identifiers specified for use in the IGS long product filename convention v2: https://files.igs.org/pub/resource/guidelines/Guidelines_For_Long_Product_Filenames_in_the_IGS_v2.0.pdf - """ - FIN = SolutionType("FIN", "final") # Final products - NRT = SolutionType("NRT", "near-real time") # Near-Real Time (between ULT and RTS) - PRD = SolutionType("PRD", "predicted") # Predicted products - RAP = SolutionType("RAP", "rapid") # Rapid products - RTS = SolutionType("RTS", "real-time streamed") # Real-Time streamed products - SNX = SolutionType("SNX", "sinex combination") # SINEX Combination product - ULT = SolutionType("ULT", "ultra-rapid") # Ultra-rapid products (every 6 hours) + Also see here for information on session lengths of products pubished by IGS: https://igs.org/products/#about + """ - # Internal representation of unknown, for contexts where defaults are passed as strings. - UNK = SolutionType("UNK", "unknown solution type") + FIN = FIN # Final products + NRT = NRT # Near-Real Time (between ULT and RTS) + PRD = PRD # Predicted products + RAP = RAP # Rapid products + RTS = RTS # Real-Time streamed products + SNX = SNX # SINEX Combination product + ULT = ULT # Ultra-rapid products (every 6 hours). The only orbit product from IGS which isn't a 1 day span + UNK = UNK # Internal representation of unknown. Useful in contexts where defaults are passed as strings. # To support search function below - _all: list[SolutionType] = [FIN, NRT, PRD, RAP, RTS, SNX, ULT, UNK] + _all: list[type[SolutionType]] = [FIN, NRT, PRD, RAP, RTS, SNX, ULT, UNK] @staticmethod - def from_name(name: Optional[str]): + def from_name(name: str): """ Returns the relevant static SolutionType object, given the solution type's short name (case insensitive). - :param str name: The short name of the solution type e.g. 'RAP', 'ULT', 'FIN', 'SNX'. Also accepts unknwon, as - either UNK or None + :param str name: The short name of the solution type e.g. 'RAP', 'ULT', 'FIN', 'SNX'. Though not part of the + official standard, 'UNK' can also be used to indicate an unknown solution type. """ - if name is None: # None is analogous to UNK - logging.debug("Converted solution type value of None, to SolutionTypes.UNK") - return SolutionTypes.UNK - + if name is None or len(name.strip()) == 0: + raise ValueError("Solution type name passed was None or effectively empty!", name) + if len(name) > 3: + raise ValueError("Long solution type names are not supported here. Please use RAP, ULT, etc.", name) name = name.upper() for solution_type in SolutionTypes._all: if name == solution_type.name: From 506dcb26fc54e0b548f00fae8f717daa20d950d7 Mon Sep 17 00:00:00 2001 From: Nathan <95725385+treefern@users.noreply.github.com> Date: Mon, 2 Sep 2024 08:14:32 +0000 Subject: [PATCH 5/6] NPI-3458 further refinement of SolutionType classes, addition of unit tests --- gnssanalysis/solution_types.py | 6 +++- tests/test_solution_type.py | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/test_solution_type.py diff --git a/gnssanalysis/solution_types.py b/gnssanalysis/solution_types.py index 7d7987c..e661e76 100644 --- a/gnssanalysis/solution_types.py +++ b/gnssanalysis/solution_types.py @@ -5,6 +5,7 @@ # Abstract base class. Leverages above Immutable metaclass to prevent its (effectively) constants, from being modified. +# Note that this doesn't prevent everything. For example, the contents of a list can still be changed. class SolutionType(metaclass=EnumMetaProperties): name: str long_name: str @@ -86,7 +87,7 @@ class UNK(SolutionType): long_name = "unknown solution type" -class SolutionTypes: +class SolutionTypes(metaclass=EnumMetaProperties): """ Defines valid solution type identifiers specified for use in the IGS long product filename convention v2: https://files.igs.org/pub/resource/guidelines/Guidelines_For_Long_Product_Filenames_in_the_IGS_v2.0.pdf @@ -94,6 +95,9 @@ class SolutionTypes: Also see here for information on session lengths of products pubished by IGS: https://igs.org/products/#about """ + def __init__(self): + raise Exception("This is intended to act akin to an enum. Don't instantiate it.") + FIN = FIN # Final products NRT = NRT # Near-Real Time (between ULT and RTS) PRD = PRD # Predicted products diff --git a/tests/test_solution_type.py b/tests/test_solution_type.py new file mode 100644 index 0000000..80b87f1 --- /dev/null +++ b/tests/test_solution_type.py @@ -0,0 +1,51 @@ +import unittest + +from gnssanalysis.solution_types import SolutionType, SolutionTypes + + +class TestSp3(unittest.TestCase): + def test_shortname_to_solution_type(self): + self.assertEqual(SolutionTypes.from_name("ULT"), SolutionTypes.ULT) + self.assertEqual(SolutionTypes.from_name("RAP"), SolutionTypes.RAP) + self.assertEqual(SolutionTypes.from_name("UNK"), SolutionTypes.UNK) + # AssertRaises can be used either as a context manager, or by breaking out the function arguments. + # Note we're not *calling* the function, we're passing the function *so it can be called* by the handler. + self.assertRaises(ValueError, SolutionTypes.from_name, name="noo") + self.assertRaises(ValueError, SolutionTypes.from_name, name="rapid") + self.assertRaises(ValueError, SolutionTypes.from_name, name="") + self.assertRaises(ValueError, SolutionTypes.from_name, name=" ") + + def test_immutability(self): + def update_base_attribute(): + SolutionType.name = "someNewValue" + + # Note that *contents* of a list can still be modified, despite the more general + # protections provided by the metaclass + + def update_enum_attribute_new(): + SolutionTypes.ULT.name = "someBogusValue" + + self.assertRaises(AttributeError, update_base_attribute) + self.assertRaises(AttributeError, update_enum_attribute_new) + + def instantiate_solution_generic(): + SolutionType() + + def instantiate_solution_specific(): + SolutionTypes.RAP() + + def instantiate_solution_helper(): + SolutionTypes() + + self.assertRaises(Exception, instantiate_solution_generic) + self.assertRaises(Exception, instantiate_solution_specific) + self.assertRaises(Exception, instantiate_solution_helper) + + def test_equality(self): + self.assertEqual(SolutionTypes.RAP, SolutionTypes.RAP, "References to same solution type class should be equal") + self.assertEqual( + SolutionTypes.from_name("RAP"), + SolutionTypes.from_name("rap"), + "from_name should give equal results each time, also regardless of case of input", + ) + self.assertNotEqual(SolutionTypes.RAP, SolutionTypes.UNK, "Non-matching solution types should be unequal") From 84cd8a0f14a817f354cd25a327d73b7dbe7b37ad Mon Sep 17 00:00:00 2001 From: Nathan <95725385+treefern@users.noreply.github.com> Date: Mon, 2 Sep 2024 08:18:07 +0000 Subject: [PATCH 6/6] NPI-3458 fix typo --- tests/test_solution_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_solution_type.py b/tests/test_solution_type.py index 80b87f1..55edde5 100644 --- a/tests/test_solution_type.py +++ b/tests/test_solution_type.py @@ -3,7 +3,7 @@ from gnssanalysis.solution_types import SolutionType, SolutionTypes -class TestSp3(unittest.TestCase): +class TestSolutionType(unittest.TestCase): def test_shortname_to_solution_type(self): self.assertEqual(SolutionTypes.from_name("ULT"), SolutionTypes.ULT) self.assertEqual(SolutionTypes.from_name("RAP"), SolutionTypes.RAP)