| 
 | 1 | +import contextlib  | 
1 | 2 | import datetime  | 
2 | 3 | import re  | 
3 | 4 | import tempfile  | 
 | 
14 | 15 |     Field,  | 
15 | 16 |     NonNegativeFloat,  | 
16 | 17 |     NonNegativeInt,  | 
 | 18 | +    StrictFloat,  | 
 | 19 | +    StrictInt,  | 
17 | 20 |     StringConstraints,  | 
 | 21 | +    TypeAdapter,  | 
 | 22 | +    ValidationError,  | 
18 | 23 |     field_validator,  | 
19 | 24 | )  | 
20 | 25 | from pydantic.config import JsonDict  | 
21 | 26 | from types_aiobotocore_ec2.literals import InstanceStateNameType, InstanceTypeType  | 
22 | 27 | 
 
  | 
 | 28 | +GenericResourceValueType: TypeAlias = StrictInt | StrictFloat | str  | 
 | 29 | + | 
23 | 30 | 
 
  | 
24 | 31 | class Resources(BaseModel, frozen=True):  | 
25 | 32 |     cpus: NonNegativeFloat  | 
26 | 33 |     ram: ByteSize  | 
 | 34 | +    generic_resources: Annotated[  | 
 | 35 | +        dict[str, GenericResourceValueType],  | 
 | 36 | +        Field(  | 
 | 37 | +            default_factory=dict,  | 
 | 38 | +            description=(  | 
 | 39 | +                "Arbitrary additional resources (e.g. {'threads': 8}). "  | 
 | 40 | +                "Numeric values are treated as quantities and participate in add/sub/compare."  | 
 | 41 | +            ),  | 
 | 42 | +        ),  | 
 | 43 | +    ] = DEFAULT_FACTORY  | 
27 | 44 | 
 
  | 
28 | 45 |     @classmethod  | 
29 | 46 |     def create_as_empty(cls) -> "Resources":  | 
30 | 47 |         return cls(cpus=0, ram=ByteSize(0))  | 
31 | 48 | 
 
  | 
32 | 49 |     def __ge__(self, other: "Resources") -> bool:  | 
33 |  | -        return self.cpus >= other.cpus and self.ram >= other.ram  | 
 | 50 | +        """operator for >= comparison  | 
 | 51 | +        if self has greater or equal resources than other, returns True  | 
 | 52 | +        This will return True only if all of the resources in self are greater or equal to other  | 
 | 53 | +
  | 
 | 54 | +        Note that generic_resources are compared only if they are numeric  | 
 | 55 | +        Non-numeric generic resources must be equal in both or only defined in self  | 
 | 56 | +        to be considered greater or equal  | 
 | 57 | +        """  | 
 | 58 | +        if self == other:  | 
 | 59 | +            return True  | 
 | 60 | +        return self > other  | 
34 | 61 | 
 
  | 
35 | 62 |     def __gt__(self, other: "Resources") -> bool:  | 
36 |  | -        return self.cpus > other.cpus or self.ram > other.ram  | 
 | 63 | +        """operator for > comparison  | 
 | 64 | +        if self has resources greater than other, returns True  | 
 | 65 | +        This will return True only if all of the resources in self are greater than other  | 
 | 66 | +
  | 
 | 67 | +        Note that generic_resources are compared only if they are numeric  | 
 | 68 | +        Non-numeric generic resources must only be defined in self  | 
 | 69 | +        to be considered greater  | 
 | 70 | +        """  | 
 | 71 | +        if (self.cpus < other.cpus) or (self.ram < other.ram):  | 
 | 72 | +            return False  | 
 | 73 | + | 
 | 74 | +        keys = set(self.generic_resources) | set(other.generic_resources)  | 
 | 75 | +        for k in keys:  | 
 | 76 | +            a = self.generic_resources.get(k)  | 
 | 77 | +            b = other.generic_resources.get(k)  | 
 | 78 | +            if a is None:  | 
 | 79 | +                return False  | 
 | 80 | +            if b is None:  | 
 | 81 | +                # a is greater as b is not defined  | 
 | 82 | +                continue  | 
 | 83 | +            if isinstance(a, int | float) and isinstance(b, int | float):  | 
 | 84 | +                if a < b:  | 
 | 85 | +                    return False  | 
 | 86 | +            else:  | 
 | 87 | +                # remaining options is a is str and b is str or mixed types  | 
 | 88 | +                # NOTE: we cannot compare strings unless they are equal or some kind of boolean (e.g. "true", "false", "yes", "no", "1", "0")  | 
 | 89 | +                assert isinstance(a, str)  # nosec  | 
 | 90 | +                assert isinstance(b, int | float | str)  # nosec  | 
 | 91 | +                # let's try to get a boolean out of the values to compare them  | 
 | 92 | +                with contextlib.suppress(ValidationError):  | 
 | 93 | +                    a_as_boolean = TypeAdapter(bool).validate_python(a)  | 
 | 94 | +                    b_as_boolean = TypeAdapter(bool).validate_python(b)  | 
 | 95 | +                    if not a_as_boolean and b_as_boolean:  | 
 | 96 | +                        return False  | 
 | 97 | + | 
 | 98 | +        # here we have either everything greater or equal or non-comparable strings  | 
 | 99 | + | 
 | 100 | +        return self != other  | 
37 | 101 | 
 
  | 
38 | 102 |     def __add__(self, other: "Resources") -> "Resources":  | 
 | 103 | +        """operator for adding two Resources  | 
 | 104 | +        Note that only numeric generic resources are added  | 
 | 105 | +        Non-numeric generic resources are ignored  | 
 | 106 | +        """  | 
 | 107 | +        merged: dict[str, GenericResourceValueType] = {}  | 
 | 108 | +        keys = set(self.generic_resources) | set(other.generic_resources)  | 
 | 109 | +        for k in keys:  | 
 | 110 | +            a = self.generic_resources.get(k)  | 
 | 111 | +            b = other.generic_resources.get(k)  | 
 | 112 | +            # adding non numeric values does not make sense, so we skip those for the resulting resource  | 
 | 113 | +            if isinstance(a, int | float) and isinstance(b, int | float):  | 
 | 114 | +                merged[k] = a + b  | 
 | 115 | +            elif a is None and isinstance(b, int | float):  | 
 | 116 | +                merged[k] = b  | 
 | 117 | +            elif b is None and isinstance(a, int | float):  | 
 | 118 | +                merged[k] = a  | 
 | 119 | + | 
39 | 120 |         return Resources.model_construct(  | 
40 |  | -            **{  | 
41 |  | -                key: a + b  | 
42 |  | -                for (key, a), b in zip(  | 
43 |  | -                    self.model_dump().items(), other.model_dump().values(), strict=True  | 
44 |  | -                )  | 
45 |  | -            }  | 
 | 121 | +            cpus=self.cpus + other.cpus,  | 
 | 122 | +            ram=self.ram + other.ram,  | 
 | 123 | +            generic_resources=merged,  | 
46 | 124 |         )  | 
47 | 125 | 
 
  | 
48 | 126 |     def __sub__(self, other: "Resources") -> "Resources":  | 
 | 127 | +        """operator for subtracting two Resources  | 
 | 128 | +        Note that only numeric generic resources are subtracted  | 
 | 129 | +        Non-numeric generic resources are ignored  | 
 | 130 | +        """  | 
 | 131 | +        merged: dict[str, GenericResourceValueType] = {}  | 
 | 132 | +        keys = set(self.generic_resources) | set(other.generic_resources)  | 
 | 133 | +        for k in keys:  | 
 | 134 | +            a = self.generic_resources.get(k)  | 
 | 135 | +            b = other.generic_resources.get(k)  | 
 | 136 | +            # subtracting non numeric values does not make sense, so we skip those for the resulting resource  | 
 | 137 | +            if isinstance(a, int | float) and isinstance(b, int | float):  | 
 | 138 | +                merged[k] = a - b  | 
 | 139 | +            elif a is None and isinstance(b, int | float):  | 
 | 140 | +                merged[k] = -b  | 
 | 141 | +            elif b is None and isinstance(a, int | float):  | 
 | 142 | +                merged[k] = a  | 
 | 143 | + | 
49 | 144 |         return Resources.model_construct(  | 
50 |  | -            **{  | 
51 |  | -                key: a - b  | 
52 |  | -                for (key, a), b in zip(  | 
53 |  | -                    self.model_dump().items(), other.model_dump().values(), strict=True  | 
54 |  | -                )  | 
55 |  | -            }  | 
 | 145 | +            cpus=self.cpus - other.cpus,  | 
 | 146 | +            ram=self.ram - other.ram,  | 
 | 147 | +            generic_resources=merged,  | 
 | 148 | +        )  | 
 | 149 | + | 
 | 150 | +    def __hash__(self) -> int:  | 
 | 151 | +        """Deterministic hash including cpus, ram (in bytes) and generic_resources."""  | 
 | 152 | +        # sort generic_resources items to ensure order-independent hashing  | 
 | 153 | +        generic_items: tuple[tuple[str, GenericResourceValueType], ...] = tuple(  | 
 | 154 | +            sorted(self.generic_resources.items())  | 
 | 155 | +        )  | 
 | 156 | +        return hash((self.cpus, self.ram, generic_items))  | 
 | 157 | + | 
 | 158 | +    def as_flat_dict(self) -> dict[str, int | float | str]:  | 
 | 159 | +        """Like model_dump, but flattens generic_resources to top level keys"""  | 
 | 160 | +        base = self.model_dump()  | 
 | 161 | +        base.update(base.pop("generic_resources"))  | 
 | 162 | +        return base  | 
 | 163 | + | 
 | 164 | +    @classmethod  | 
 | 165 | +    def from_flat_dict(  | 
 | 166 | +        cls,  | 
 | 167 | +        data: dict[str, int | float | str],  | 
 | 168 | +        *,  | 
 | 169 | +        mapping: dict[str, str] | None = None,  | 
 | 170 | +    ) -> "Resources":  | 
 | 171 | +        """Inverse of as_flat_dict with optional key mapping"""  | 
 | 172 | +        mapped_data = data  | 
 | 173 | +        if mapping:  | 
 | 174 | +            mapped_data = {mapping.get(k, k): v for k, v in data.items()}  | 
 | 175 | +        generic_resources = {  | 
 | 176 | +            k: v for k, v in mapped_data.items() if k not in {"cpus", "ram"}  | 
 | 177 | +        }  | 
 | 178 | + | 
 | 179 | +        return cls(  | 
 | 180 | +            cpus=float(mapped_data.get("cpus", 0)),  | 
 | 181 | +            ram=ByteSize(mapped_data.get("ram", 0)),  | 
 | 182 | +            generic_resources=generic_resources,  | 
56 | 183 |         )  | 
57 | 184 | 
 
  | 
58 | 185 |     @field_validator("cpus", mode="before")  | 
@@ -174,8 +301,9 @@ def validate_bash_calls(cls, v):  | 
174 | 301 |                 temp_file.flush()  | 
175 | 302 |                 # NOTE: this will not capture runtime errors, but at least some syntax errors such as invalid quotes  | 
176 | 303 |                 sh.bash(  | 
177 |  | -                    "-n", temp_file.name  | 
178 |  | -                )  # pyright: ignore[reportCallIssue]  # sh is untyped, but this call is safe for bash syntax checking  | 
 | 304 | +                    "-n",  | 
 | 305 | +                    temp_file.name,  # pyright: ignore[reportCallIssue] - sh is untyped but safe for bash syntax checking  | 
 | 306 | +                )  | 
179 | 307 |         except sh.ErrorReturnCode as exc:  | 
180 | 308 |             msg = f"Invalid bash call in custom_boot_scripts: {v}, Error: {exc.stderr}"  | 
181 | 309 |             raise ValueError(msg) from exc  | 
 | 
0 commit comments