service-capacity-modeling 0.3.88__tar.gz → 0.3.90__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/PKG-INFO +1 -1
  2. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/enum_utils.py +42 -0
  3. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/interface.py +9 -12
  4. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/cassandra.py +50 -38
  5. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/counter.py +6 -6
  6. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/evcache.py +2 -2
  7. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/kafka.py +2 -2
  8. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling.egg-info/PKG-INFO +1 -1
  9. service_capacity_modeling-0.3.90/tests/test_enum_utils.py +316 -0
  10. service_capacity_modeling-0.3.88/tests/test_enum_utils.py +0 -140
  11. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/LICENSE +0 -0
  12. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/README.md +0 -0
  13. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/__init__.py +0 -0
  14. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/capacity_planner.py +0 -0
  15. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/__init__.py +0 -0
  16. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/__init__.py +0 -0
  17. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/pricing/aws/3yr-reserved_ec2.json +0 -0
  18. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/pricing/aws/3yr-reserved_zz-overrides.json +0 -0
  19. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/profiles.txt +0 -0
  20. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5.json +0 -0
  21. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5a.json +0 -0
  22. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5d.json +0 -0
  23. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5n.json +0 -0
  24. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c6a.json +0 -0
  25. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c6i.json +0 -0
  26. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c6id.json +0 -0
  27. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c7a.json +0 -0
  28. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c7i.json +0 -0
  29. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c8i.json +0 -0
  30. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_i3en.json +0 -0
  31. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_i4i.json +0 -0
  32. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_i7i.json +0 -0
  33. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m4.json +0 -0
  34. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m5.json +0 -0
  35. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m5n.json +0 -0
  36. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6a.json +0 -0
  37. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6i.json +0 -0
  38. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6id.json +0 -0
  39. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6idn.json +0 -0
  40. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6in.json +0 -0
  41. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m7a.json +0 -0
  42. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m7i.json +0 -0
  43. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m8i.json +0 -0
  44. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r4.json +0 -0
  45. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r5.json +0 -0
  46. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r5n.json +0 -0
  47. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6a.json +0 -0
  48. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6i.json +0 -0
  49. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6id.json +0 -0
  50. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6idn.json +0 -0
  51. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6in.json +0 -0
  52. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r7a.json +0 -0
  53. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r7i.json +0 -0
  54. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r8i.json +0 -0
  55. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/manual_drives.json +0 -0
  56. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/manual_instances.json +0 -0
  57. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/hardware/profiles/shapes/aws/manual_services.json +0 -0
  58. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/__init__.py +0 -0
  59. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/common.py +0 -0
  60. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/headroom_strategy.py +0 -0
  61. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/__init__.py +0 -0
  62. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/__init__.py +0 -0
  63. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/aurora.py +0 -0
  64. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/control.py +0 -0
  65. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/crdb.py +0 -0
  66. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/ddb.py +0 -0
  67. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/elasticsearch.py +0 -0
  68. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/entity.py +0 -0
  69. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/graphkv.py +0 -0
  70. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/iso_date_math.py +0 -0
  71. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/key_value.py +0 -0
  72. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/postgres.py +0 -0
  73. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/rds.py +0 -0
  74. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/stateless_java.py +0 -0
  75. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/time_series.py +0 -0
  76. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/time_series_config.py +0 -0
  77. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/wal.py +0 -0
  78. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/org/netflix/zookeeper.py +0 -0
  79. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/models/utils.py +0 -0
  80. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/stats.py +0 -0
  81. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/tools/__init__.py +0 -0
  82. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/tools/auto_shape.py +0 -0
  83. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/tools/fetch_pricing.py +0 -0
  84. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/tools/generate_missing.py +0 -0
  85. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling/tools/instance_families.py +0 -0
  86. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling.egg-info/SOURCES.txt +0 -0
  87. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling.egg-info/dependency_links.txt +0 -0
  88. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling.egg-info/entry_points.txt +0 -0
  89. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling.egg-info/requires.txt +0 -0
  90. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/service_capacity_modeling.egg-info/top_level.txt +0 -0
  91. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/setup.cfg +0 -0
  92. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/setup.py +0 -0
  93. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_arguments.py +0 -0
  94. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_buffers.py +0 -0
  95. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_common.py +0 -0
  96. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_desire_merge.py +0 -0
  97. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_generate_scenarios.py +0 -0
  98. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_hardware.py +0 -0
  99. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_hardware_shapes.py +0 -0
  100. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_headroom_strategy.py +0 -0
  101. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_io2.py +0 -0
  102. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_model_dump.py +0 -0
  103. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_reproducible.py +0 -0
  104. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_simulation.py +0 -0
  105. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_utils.py +0 -0
  106. {service_capacity_modeling-0.3.88 → service_capacity_modeling-0.3.90}/tests/test_working_set.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: service-capacity-modeling
3
- Version: 0.3.88
3
+ Version: 0.3.90
4
4
  Summary: Contains utilities for modeling capacity for pluggable workloads
5
5
  Author: Joseph Lynch
6
6
  Author-email: josephl@netflix.com
@@ -5,6 +5,7 @@ runtime-accessible docstrings.
5
5
 
6
6
  import ast
7
7
  import inspect
8
+ import sys
8
9
  from enum import Enum
9
10
  from functools import partial
10
11
  from operator import is_
@@ -16,6 +17,47 @@ from pydantic.json_schema import JsonSchemaValue
16
17
  from pydantic_core import CoreSchema
17
18
 
18
19
 
20
+ __all__ = ["StrEnum", "enum_docstrings"]
21
+
22
+ # StrEnum backport for Python 3.10 compatibility
23
+ # On Python 3.11+, use the stdlib version
24
+ if sys.version_info >= (3, 11):
25
+ from enum import StrEnum as StrEnum # pylint: disable=useless-import-alias
26
+ else:
27
+
28
+ class StrEnum(str, Enum):
29
+ """Backport of Python 3.11 StrEnum.
30
+
31
+ Provides consistent string behavior across all Python versions:
32
+ - f"{x}" returns the value (not "Foo.BAR")
33
+ - str(x) returns the value (not "Foo.BAR")
34
+ - x == "value" returns True (string comparison works)
35
+
36
+ This addresses PEP 663 which changed str(Enum) behavior in Python 3.11,
37
+ making (str, Enum) return "Foo.BAR" in f-strings instead of the value.
38
+ """
39
+
40
+ def __new__(cls, value: str, *args: Any, **kwargs: Any) -> "StrEnum":
41
+ if not isinstance(value, str):
42
+ raise TypeError(f"{value!r} is not a string")
43
+ member = str.__new__(cls, value)
44
+ member._value_ = value
45
+ return member
46
+
47
+ def __str__(self) -> str:
48
+ return str(self.value)
49
+
50
+ def __format__(self, format_spec: str) -> str:
51
+ # Ensures f-strings return value, not "Foo.BAR"
52
+ return str(self.value).__format__(format_spec)
53
+
54
+ @staticmethod
55
+ def _generate_next_value_(
56
+ name: str, start: int, count: int, last_values: list[str]
57
+ ) -> str:
58
+ return name.lower()
59
+
60
+
19
61
  E = TypeVar("E", bound=Enum)
20
62
 
21
63
 
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
  import re
5
5
  import sys
6
6
  from decimal import Decimal
7
- from enum import Enum
8
7
  from fractions import Fraction
9
8
  from functools import lru_cache
10
9
  from typing import Any
@@ -22,6 +21,7 @@ from pydantic import ConfigDict
22
21
  from pydantic import Field
23
22
 
24
23
  from service_capacity_modeling.enum_utils import enum_docstrings
24
+ from service_capacity_modeling.enum_utils import StrEnum
25
25
 
26
26
  GIB_IN_BYTES = 1024 * 1024 * 1024
27
27
  MIB_IN_BYTES = 1024 * 1024
@@ -46,16 +46,13 @@ class ExcludeUnsetModel(BaseModel):
46
46
 
47
47
 
48
48
  @enum_docstrings
49
- class IntervalModel(str, Enum):
49
+ class IntervalModel(StrEnum):
50
50
  """Statistical distribution models for approximating intervals
51
51
 
52
52
  When we have uncertainty intervals (low, mid, high), we need to choose
53
53
  a probability distribution to model that uncertainty for simulation purposes.
54
54
  """
55
55
 
56
- def __str__(self) -> str:
57
- return str(self.value)
58
-
59
56
  def __repr__(self) -> str:
60
57
  return f"D({self.value})"
61
58
 
@@ -204,7 +201,7 @@ def normalized_aws_size(name: str) -> Fraction:
204
201
  ###############################################################################
205
202
 
206
203
 
207
- class Lifecycle(str, Enum):
204
+ class Lifecycle(StrEnum):
208
205
  """Represents the lifecycle of hardware from initial preview
209
206
  to end-of-life.
210
207
 
@@ -223,7 +220,7 @@ class Lifecycle(str, Enum):
223
220
 
224
221
 
225
222
  @enum_docstrings
226
- class DriveType(str, Enum):
223
+ class DriveType(StrEnum):
227
224
  """Represents the type and attachment model of storage drives
228
225
 
229
226
  Drives can be either local (ephemeral, instance-attached) or network-attached
@@ -366,7 +363,7 @@ class Drive(ExcludeUnsetModel):
366
363
 
367
364
 
368
365
  @enum_docstrings
369
- class Platform(str, Enum):
366
+ class Platform(StrEnum):
370
367
  """Represents the CPU architecture or managed service platform
371
368
 
372
369
  Hardware can run on different CPU architectures (x86_64, ARM) or be a fully
@@ -617,7 +614,7 @@ class Pricing(ExcludeUnsetModel):
617
614
 
618
615
 
619
616
  @enum_docstrings
620
- class AccessPattern(str, Enum):
617
+ class AccessPattern(StrEnum):
621
618
  """The access pattern determines capacity planning priorities: latency-sensitive
622
619
  services target low P99 latency, while throughput-oriented services optimize
623
620
  for maximum requests per second.
@@ -633,7 +630,7 @@ class AccessPattern(str, Enum):
633
630
 
634
631
 
635
632
  @enum_docstrings
636
- class AccessConsistency(str, Enum):
633
+ class AccessConsistency(StrEnum):
637
634
  """
638
635
  Generally speaking consistency is expensive, so models need to know what
639
636
  kind of consistency will be required in order to estimate CPU usage
@@ -851,7 +848,7 @@ class CurrentClusters(ExcludeUnsetModel):
851
848
 
852
849
 
853
850
  @enum_docstrings
854
- class BufferComponent(str, Enum):
851
+ class BufferComponent(StrEnum):
855
852
  """Represents well known buffer components such as compute and storage
856
853
 
857
854
  Note that while these are common and defined here for models to share,
@@ -887,7 +884,7 @@ class BufferComponent(str, Enum):
887
884
 
888
885
 
889
886
  @enum_docstrings
890
- class BufferIntent(str, Enum):
887
+ class BufferIntent(StrEnum):
891
888
  """Defines the intent of buffer directives for capacity planning"""
892
889
 
893
890
  desired = "desired"
@@ -638,8 +638,14 @@ def _target_rf(desires: CapacityDesires, user_copies: Optional[int]) -> int:
638
638
 
639
639
 
640
640
  class NflxCassandraArguments(BaseModel):
641
- copies_per_region: int = Field(
642
- default=3,
641
+ """Configuration arguments for the Netflix Cassandra capacity model.
642
+
643
+ This model centralizes all tunable parameters with their defaults.
644
+ Use `from_extra_model_arguments()` to parse a dict into a validated instance.
645
+ """
646
+
647
+ copies_per_region: Optional[int] = Field(
648
+ default=None,
643
649
  description="How many copies of the data will exist e.g. RF=3. If unsupplied"
644
650
  " this will be deduced from durability and consistency desires",
645
651
  )
@@ -663,9 +669,13 @@ class NflxCassandraArguments(BaseModel):
663
669
  default=192,
664
670
  description="What is the maximum size of a cluster in this region",
665
671
  )
666
- max_local_disk_gib: int = Field(
667
- default=5120,
668
- description="The maximum amount of data we store per machine",
672
+ max_local_data_per_node_gib: int = Field(
673
+ default=1280,
674
+ description="Maximum data per node for local disk instances (GiB)",
675
+ )
676
+ max_attached_data_per_node_gib: int = Field(
677
+ default=2048,
678
+ description="Maximum data per node for attached disk instances (GiB)",
669
679
  )
670
680
  max_write_buffer_percent: float = Field(
671
681
  default=0.25,
@@ -680,6 +690,26 @@ class NflxCassandraArguments(BaseModel):
680
690
  "automatically adjust to 0.2",
681
691
  )
682
692
 
693
+ @classmethod
694
+ def from_extra_model_arguments(
695
+ cls, extra_model_arguments: Dict[str, Any]
696
+ ) -> "NflxCassandraArguments":
697
+ """Parse extra_model_arguments dict into a validated NflxCassandraArguments.
698
+
699
+ This centralizes default values - any field not in extra_model_arguments
700
+ will use the default defined in this model.
701
+
702
+ Handles legacy field name mappings:
703
+ - max_local_disk_gib -> max_local_data_per_node_gib (if not explicitly set)
704
+ """
705
+ # Handle legacy field name: max_local_disk_gib -> max_local_data_per_node_gib
706
+ args = dict(extra_model_arguments)
707
+ if "max_local_data_per_node_gib" not in args and "max_local_disk_gib" in args:
708
+ args["max_local_data_per_node_gib"] = args["max_local_disk_gib"]
709
+
710
+ # Pydantic will use defaults for any missing fields
711
+ return cls.model_validate(args)
712
+
683
713
 
684
714
  class NflxCassandraCapacityModel(CapacityModel):
685
715
  def __init__(self) -> None:
@@ -722,40 +752,22 @@ class NflxCassandraCapacityModel(CapacityModel):
722
752
  desires: CapacityDesires,
723
753
  extra_model_arguments: Dict[str, Any],
724
754
  ) -> Optional[CapacityPlan]:
725
- # TODO: Standardize these extra model argument defaults in a single
726
- # place. Many of them are defined here and as default values in the
727
- # downstream method but only these ones are used which is confusing for
728
- # readability
729
-
730
- # Use durabiliy and consistency to compute RF.
731
- copies_per_region = _target_rf(
732
- desires, extra_model_arguments.get("copies_per_region", None)
733
- )
734
- require_local_disks: bool = extra_model_arguments.get(
735
- "require_local_disks", False
736
- )
737
- require_attached_disks: bool = extra_model_arguments.get(
738
- "require_attached_disks", False
739
- )
755
+ # Parse extra_model_arguments into a validated model with centralized defaults
756
+ args = NflxCassandraArguments.from_extra_model_arguments(extra_model_arguments)
757
+
758
+ # Use durability and consistency to compute RF if not explicitly set
759
+ copies_per_region = _target_rf(desires, args.copies_per_region)
760
+
761
+ # Validate required_cluster_size for critical tiers
740
762
  required_cluster_size: Optional[int] = (
741
763
  NflxCassandraCapacityModel.get_required_cluster_size(
742
764
  desires.service_tier, extra_model_arguments
743
765
  )
744
766
  )
745
767
 
746
- max_rps_to_disk: int = extra_model_arguments.get("max_rps_to_disk", 500)
747
- max_regional_size: int = extra_model_arguments.get("max_regional_size", 192)
748
- max_local_data_per_node_gib: int = extra_model_arguments.get(
749
- "max_local_data_per_node_gib",
750
- extra_model_arguments.get("max_local_disk_gib", 1280),
751
- )
752
-
753
- max_write_buffer_percent: float = min(
754
- 0.5, extra_model_arguments.get("max_write_buffer_percent", 0.25)
755
- )
756
- max_table_buffer_percent: float = min(
757
- 0.5, extra_model_arguments.get("max_table_buffer_percent", 0.11)
758
- )
768
+ # Apply caps to buffer percentages
769
+ max_write_buffer_percent = min(0.5, args.max_write_buffer_percent)
770
+ max_table_buffer_percent = min(0.5, args.max_table_buffer_percent)
759
771
 
760
772
  # Adjust heap defaults for high write clusters
761
773
  if (
@@ -772,12 +784,12 @@ class NflxCassandraCapacityModel(CapacityModel):
772
784
  desires=desires,
773
785
  zones_per_region=context.zones_in_region,
774
786
  copies_per_region=copies_per_region,
775
- require_local_disks=require_local_disks,
776
- require_attached_disks=require_attached_disks,
787
+ require_local_disks=args.require_local_disks,
788
+ require_attached_disks=args.require_attached_disks,
777
789
  required_cluster_size=required_cluster_size,
778
- max_rps_to_disk=max_rps_to_disk,
779
- max_regional_size=max_regional_size,
780
- max_local_data_per_node_gib=max_local_data_per_node_gib,
790
+ max_rps_to_disk=args.max_rps_to_disk,
791
+ max_regional_size=args.max_regional_size,
792
+ max_local_data_per_node_gib=args.max_local_data_per_node_gib,
781
793
  max_write_buffer_percent=max_write_buffer_percent,
782
794
  max_table_buffer_percent=max_table_buffer_percent,
783
795
  )
@@ -1,5 +1,4 @@
1
1
  from datetime import timedelta
2
- from enum import Enum
3
2
  from typing import Any
4
3
  from typing import Callable
5
4
  from typing import Dict
@@ -10,6 +9,7 @@ from pydantic import Field
10
9
 
11
10
  from .stateless_java import nflx_java_app_capacity_model
12
11
  from .stateless_java import NflxJavaAppArguments
12
+ from service_capacity_modeling.enum_utils import StrEnum
13
13
  from service_capacity_modeling.interface import AccessConsistency
14
14
  from service_capacity_modeling.interface import AccessPattern
15
15
  from service_capacity_modeling.interface import CapacityDesires
@@ -26,13 +26,13 @@ from service_capacity_modeling.interface import RegionContext
26
26
  from service_capacity_modeling.models import CapacityModel
27
27
 
28
28
 
29
- class NflxCounterCardinality(Enum):
29
+ class NflxCounterCardinality(StrEnum):
30
30
  low = "low"
31
31
  medium = "medium"
32
32
  high = "high"
33
33
 
34
34
 
35
- class NflxCounterMode(Enum):
35
+ class NflxCounterMode(StrEnum):
36
36
  best_effort = "best-effort"
37
37
  eventual = "eventual"
38
38
  exact = "exact"
@@ -95,7 +95,7 @@ class NflxCounterCapacityModel(CapacityModel):
95
95
  ) -> Tuple[Tuple[str, Callable[[CapacityDesires], CapacityDesires]], ...]:
96
96
  stores = []
97
97
 
98
- if extra_model_arguments["counter.mode"] == NflxCounterMode.best_effort.value:
98
+ if extra_model_arguments["counter.mode"] == NflxCounterMode.best_effort:
99
99
  stores.append(("org.netflix.evcache", lambda x: x))
100
100
  else:
101
101
  # Shared evcache cluster is used for eventual and exact counters
@@ -114,9 +114,9 @@ class NflxCounterCapacityModel(CapacityModel):
114
114
  # high cardinality : rollups happen once every 10 seconds
115
115
  # TODO: Account for read amplification from time slice configs
116
116
  # for better model accuracy
117
- if counter_cardinality == NflxCounterCardinality.low.value:
117
+ if counter_cardinality == NflxCounterCardinality.low:
118
118
  rollups_per_second = counter_deltas_per_second.scale(0.0167)
119
- elif counter_cardinality == NflxCounterCardinality.medium.value:
119
+ elif counter_cardinality == NflxCounterCardinality.medium:
120
120
  rollups_per_second = counter_deltas_per_second.scale(0.0333)
121
121
  else:
122
122
  rollups_per_second = counter_deltas_per_second.scale(0.1)
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  import math
3
- from enum import Enum
4
3
  from typing import Any
5
4
  from typing import Dict
6
5
  from typing import Optional
@@ -9,6 +8,7 @@ from typing import Tuple
9
8
  from pydantic import BaseModel
10
9
  from pydantic import Field
11
10
 
11
+ from service_capacity_modeling.enum_utils import StrEnum
12
12
  from service_capacity_modeling.interface import AccessConsistency
13
13
  from service_capacity_modeling.interface import AccessPattern
14
14
  from service_capacity_modeling.interface import Buffer
@@ -45,7 +45,7 @@ from service_capacity_modeling.stats import dist_for_interval
45
45
  logger = logging.getLogger(__name__)
46
46
 
47
47
 
48
- class Replication(str, Enum):
48
+ class Replication(StrEnum):
49
49
  none = "none"
50
50
  sets = "sets"
51
51
  evicts = "evicts"
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  import math
3
- from enum import Enum
4
3
  from typing import Any
5
4
  from typing import Dict
6
5
  from typing import Optional
@@ -9,6 +8,7 @@ from typing import Tuple
9
8
  from pydantic import BaseModel
10
9
  from pydantic import Field
11
10
 
11
+ from service_capacity_modeling.enum_utils import StrEnum
12
12
  from service_capacity_modeling.interface import AccessConsistency
13
13
  from service_capacity_modeling.interface import AccessPattern
14
14
  from service_capacity_modeling.interface import Buffer
@@ -45,7 +45,7 @@ from service_capacity_modeling.models.org.netflix.iso_date_math import iso_to_se
45
45
  logger = logging.getLogger(__name__)
46
46
 
47
47
 
48
- class ClusterType(str, Enum):
48
+ class ClusterType(StrEnum):
49
49
  strong = "strong"
50
50
  ha = "high-availability"
51
51
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: service-capacity-modeling
3
- Version: 0.3.88
3
+ Version: 0.3.90
4
4
  Summary: Contains utilities for modeling capacity for pluggable workloads
5
5
  Author: Joseph Lynch
6
6
  Author-email: josephl@netflix.com
@@ -0,0 +1,316 @@
1
+ import pytest
2
+ from pydantic import BaseModel
3
+ from pydantic import ValidationError
4
+
5
+ from service_capacity_modeling.interface import AccessConsistency
6
+ from service_capacity_modeling.interface import AccessPattern
7
+ from service_capacity_modeling.interface import BufferComponent
8
+ from service_capacity_modeling.interface import BufferIntent
9
+ from service_capacity_modeling.interface import DriveType
10
+ from service_capacity_modeling.interface import IntervalModel
11
+ from service_capacity_modeling.interface import Lifecycle
12
+ from service_capacity_modeling.interface import Platform
13
+ from service_capacity_modeling.models.org.netflix.counter import NflxCounterCardinality
14
+ from service_capacity_modeling.models.org.netflix.counter import NflxCounterMode
15
+ from service_capacity_modeling.models.org.netflix.evcache import Replication
16
+ from service_capacity_modeling.models.org.netflix.kafka import ClusterType
17
+
18
+ # List of all enums that should have per-member docstrings
19
+ DOCUMENTED_ENUMS = [
20
+ IntervalModel,
21
+ DriveType,
22
+ Platform,
23
+ AccessPattern,
24
+ AccessConsistency,
25
+ BufferComponent,
26
+ BufferIntent,
27
+ ]
28
+
29
+
30
+ @pytest.mark.parametrize("enum_class", DOCUMENTED_ENUMS)
31
+ def test_enums_have_docstrings(enum_class):
32
+ """Test that all interface.py enums have comprehensive per-member
33
+ docstrings
34
+
35
+ This test ensures that all enum members have their own
36
+ runtime-accessible docstrings, which makes them discoverable via
37
+ help(), IDE tooltips, and the __doc__ attribute.
38
+
39
+ The enums use the @enum_docstrings decorator which parses source code
40
+ to attach docstrings that appear below each member (following PEP 257
41
+ attribute docstring conventions).
42
+
43
+ See: https://stackoverflow.com/questions/19330460/
44
+ how-do-i-put-docstrings-on-enums
45
+ """
46
+ enum_name = enum_class.__name__
47
+
48
+ # Check class has a docstring
49
+ assert enum_class.__doc__ is not None, f"{enum_name} must have a class docstring"
50
+ assert len(enum_class.__doc__) > 0, f"{enum_name} class docstring must not be empty"
51
+
52
+ # Check each member has its own unique docstring
53
+ for member in enum_class:
54
+ assert member.__doc__ is not None, (
55
+ f"{enum_name}.{member.name} must have a docstring. "
56
+ f"Add a docstring after the member definition:\n"
57
+ f' {member.name} = "{member.value}"\n'
58
+ f' """Your documentation here"""'
59
+ )
60
+ assert len(member.__doc__.strip()) > 0, (
61
+ f"{enum_name}.{member.name} docstring must not be empty"
62
+ )
63
+ # Verify it's not just the class docstring (should be member-specific)
64
+ assert member.__doc__ != enum_class.__doc__, (
65
+ f"{enum_name}.{member.name} should have its own docstring, "
66
+ f"not inherit the class docstring. "
67
+ f"Did the @enum_docstrings decorator work?"
68
+ )
69
+
70
+ # Verify different members have different docstrings
71
+ members = list(enum_class)
72
+ if len(members) >= 2:
73
+ assert members[0].__doc__ != members[1].__doc__, (
74
+ f"{enum_name}: Different enum members should have different docstrings"
75
+ )
76
+
77
+
78
+ @pytest.mark.parametrize("enum_class", DOCUMENTED_ENUMS)
79
+ def test_enums_json_schema_includes_member_docstrings(enum_class):
80
+ """Test that enum member docstrings appear in Pydantic JSON schemas
81
+
82
+ The @enum_docstrings decorator adds __get_pydantic_json_schema__ to
83
+ generate oneOf schemas with per-member descriptions. This ensures
84
+ enum documentation is available in API schemas, OpenAPI specs, etc.
85
+ """
86
+ enum_name = enum_class.__name__
87
+
88
+ # Create a test model using this enum
89
+ TestModel = type(
90
+ "TestModel",
91
+ (BaseModel,),
92
+ {"__annotations__": {"field": enum_class}},
93
+ )
94
+
95
+ # Get the JSON schema
96
+ schema = TestModel.model_json_schema()
97
+
98
+ # Check the enum definition exists in $defs
99
+ assert "$defs" in schema, f"{enum_name}: Schema missing $defs"
100
+ assert enum_name in schema["$defs"], f"{enum_name}: Enum not in $defs"
101
+
102
+ enum_schema = schema["$defs"][enum_name]
103
+
104
+ # Check oneOf exists with member descriptions
105
+ assert "oneOf" in enum_schema, (
106
+ f"{enum_name}: JSON schema missing oneOf for member descriptions"
107
+ )
108
+
109
+ one_of = enum_schema["oneOf"]
110
+ assert len(one_of) == len(enum_class), (
111
+ f"{enum_name}: oneOf should have {len(enum_class)} entries"
112
+ )
113
+
114
+ # Verify each member has proper schema entry
115
+ for member in enum_class:
116
+ matching_entries = [
117
+ entry for entry in one_of if entry.get("const") == member.value
118
+ ]
119
+
120
+ assert len(matching_entries) == 1, (
121
+ f"{enum_name}.{member.name}: Should have exactly one oneOf entry"
122
+ )
123
+
124
+ entry = matching_entries[0]
125
+
126
+ # Check required fields
127
+ assert "const" in entry, f"{enum_name}.{member.name}: Missing 'const'"
128
+ assert "title" in entry, f"{enum_name}.{member.name}: Missing 'title'"
129
+ assert "description" in entry, (
130
+ f"{enum_name}.{member.name}: Missing 'description'"
131
+ )
132
+
133
+ # Verify description matches member docstring
134
+ assert entry["description"] == member.__doc__, (
135
+ f"{enum_name}.{member.name}: Schema description doesn't match "
136
+ f"member.__doc__"
137
+ )
138
+
139
+ # Verify description is not empty and not the class docstring
140
+ assert len(entry["description"].strip()) > 0, (
141
+ f"{enum_name}.{member.name}: Description should not be empty"
142
+ )
143
+ assert entry["description"] != enum_class.__doc__, (
144
+ f"{enum_name}.{member.name}: Description should be member-specific, "
145
+ f"not the class docstring"
146
+ )
147
+
148
+
149
+ ###############################################################################
150
+ # StrEnum Behavior Tests (PEP 663) #
151
+ ###############################################################################
152
+ #
153
+ # These tests validate that StrEnum provides consistent string behavior across
154
+ # all Python versions (3.10, 3.11, 3.12).
155
+ #
156
+ # Background: PEP 663 changed (str, Enum) behavior in Python 3.11:
157
+ # - Python 3.10: f"{x}" returns value, str(x) returns "Foo.BAR"
158
+ # - Python 3.11: f"{x}" returns "Foo.BAR", str(x) returns "Foo.BAR"
159
+ #
160
+ # StrEnum provides consistent behavior where f"{x}", str(x), and x.value ALL
161
+ # return the value string, making enum usage predictable across Python versions.
162
+ #
163
+ # See: https://peps.python.org/pep-0663/
164
+
165
+
166
+ # All StrEnum classes in the codebase
167
+ STRENUM_CLASSES = [
168
+ IntervalModel,
169
+ Lifecycle,
170
+ DriveType,
171
+ Platform,
172
+ AccessPattern,
173
+ AccessConsistency,
174
+ BufferComponent,
175
+ BufferIntent,
176
+ NflxCounterCardinality,
177
+ NflxCounterMode,
178
+ Replication,
179
+ ClusterType,
180
+ ]
181
+
182
+
183
+ @pytest.mark.parametrize("enum_class", STRENUM_CLASSES)
184
+ def test_strenum_inherits_from_str(enum_class):
185
+ """Test that all enum classes inherit from str (StrEnum behavior).
186
+
187
+ This ensures the enum can be used directly in string contexts.
188
+ """
189
+ for member in enum_class:
190
+ assert isinstance(member, str), (
191
+ f"{enum_class.__name__}.{member.name} should be a str instance"
192
+ )
193
+
194
+
195
+ @pytest.mark.parametrize("enum_class", STRENUM_CLASSES)
196
+ def test_strenum_fstring_returns_value(enum_class):
197
+ """Test that f-strings return the enum value, not 'EnumName.member'.
198
+
199
+ This is the key behavior that changed in Python 3.11 (PEP 663).
200
+ Without StrEnum, f"{x}" returns "Foo.BAR" instead of "bar".
201
+ """
202
+ for member in enum_class:
203
+ fstring_result = f"{member}"
204
+ assert fstring_result == member.value, (
205
+ f'f"{{{{member}}}}" for {enum_class.__name__}.{member.name} returned '
206
+ f'"{fstring_result}", expected "{member.value}"'
207
+ )
208
+
209
+
210
+ @pytest.mark.parametrize("enum_class", STRENUM_CLASSES)
211
+ def test_strenum_str_returns_value(enum_class):
212
+ """Test that str(x) returns the enum value, not 'EnumName.member'.
213
+
214
+ With StrEnum, str(x) should return the value for consistent behavior.
215
+ """
216
+ for member in enum_class:
217
+ str_result = str(member)
218
+ assert str_result == member.value, (
219
+ f"str({enum_class.__name__}.{member.name}) returned "
220
+ f'"{str_result}", expected "{member.value}"'
221
+ )
222
+
223
+
224
+ @pytest.mark.parametrize("enum_class", STRENUM_CLASSES)
225
+ def test_strenum_format_returns_value(enum_class):
226
+ """Test that format()/format spec returns the enum value.
227
+
228
+ This uses "{}".format() which should behave like f-strings.
229
+ """
230
+ for member in enum_class:
231
+ format_result = "{}".format(member) # pylint: disable=consider-using-f-string
232
+ assert format_result == member.value, (
233
+ f'"{{}}".format({enum_class.__name__}.{member.name}) returned '
234
+ f'"{format_result}", expected "{member.value}"'
235
+ )
236
+
237
+
238
+ @pytest.mark.parametrize("enum_class", STRENUM_CLASSES)
239
+ def test_strenum_equals_string(enum_class):
240
+ """Test that enum members compare equal to their string values.
241
+
242
+ This is critical for usage with Pydantic model_dump() and dict comparisons.
243
+ """
244
+ for member in enum_class:
245
+ assert member == member.value, (
246
+ f"{enum_class.__name__}.{member.name} == {member.value!r} should be True"
247
+ )
248
+
249
+
250
+ @pytest.mark.parametrize("enum_class", STRENUM_CLASSES)
251
+ def test_strenum_works_as_dict_key_with_string_lookup(enum_class):
252
+ """Test that enum members work as dict keys with string lookup.
253
+
254
+ When model_dump() returns enum objects, we should be able to use strings
255
+ to look up values in dicts keyed by those enums.
256
+ """
257
+ for member in enum_class:
258
+ test_dict = {member: "test_value"}
259
+ # String lookup should work because member IS a string
260
+ assert test_dict.get(member.value) == "test_value", (
261
+ f"Dict keyed by {enum_class.__name__}.{member.name} should be "
262
+ f"accessible via string {member.value!r}"
263
+ )
264
+
265
+
266
+ def test_strenum_pydantic_validation_accepts_valid_strings():
267
+ """Test that Pydantic accepts valid enum string values."""
268
+
269
+ class TestModel(BaseModel):
270
+ pattern: AccessPattern
271
+ consistency: AccessConsistency
272
+
273
+ # Should accept string values
274
+ model = TestModel(pattern="latency", consistency="eventual")
275
+ assert model.pattern == AccessPattern.latency
276
+ assert model.consistency == AccessConsistency.eventual
277
+
278
+
279
+ def test_strenum_pydantic_validation_rejects_invalid_strings():
280
+ """Test that Pydantic rejects invalid enum string values.
281
+
282
+ This ensures strict validation - arbitrary strings are NOT accepted.
283
+ """
284
+
285
+ class TestModel(BaseModel):
286
+ pattern: AccessPattern
287
+
288
+ with pytest.raises(ValidationError):
289
+ TestModel(pattern="invalid_pattern_value")
290
+
291
+
292
+ def test_strenum_pydantic_model_dump_preserves_enum():
293
+ """Test that model_dump() returns enum objects that behave as strings.
294
+
295
+ By default, Pydantic returns enum objects (not raw strings) from model_dump().
296
+ With StrEnum, these objects ARE strings, so comparisons work correctly.
297
+ """
298
+
299
+ class TestModel(BaseModel):
300
+ pattern: AccessPattern
301
+ drive: DriveType
302
+
303
+ model = TestModel(pattern="latency", drive="local-ssd")
304
+ dumped = model.model_dump()
305
+
306
+ # model_dump() returns enum objects by default
307
+ assert dumped["pattern"] == AccessPattern.latency
308
+ assert dumped["drive"] == DriveType.local_ssd
309
+
310
+ # But they should compare equal to strings because they ARE strings
311
+ assert dumped["pattern"] == "latency"
312
+ assert dumped["drive"] == "local-ssd"
313
+
314
+ # And isinstance should work
315
+ assert isinstance(dumped["pattern"], str)
316
+ assert isinstance(dumped["drive"], str)
@@ -1,140 +0,0 @@
1
- import pytest
2
- from pydantic import BaseModel
3
-
4
- from service_capacity_modeling.interface import AccessConsistency
5
- from service_capacity_modeling.interface import AccessPattern
6
- from service_capacity_modeling.interface import BufferComponent
7
- from service_capacity_modeling.interface import BufferIntent
8
- from service_capacity_modeling.interface import DriveType
9
- from service_capacity_modeling.interface import IntervalModel
10
- from service_capacity_modeling.interface import Platform
11
-
12
- # List of all enums that should have per-member docstrings
13
- DOCUMENTED_ENUMS = [
14
- IntervalModel,
15
- DriveType,
16
- Platform,
17
- AccessPattern,
18
- AccessConsistency,
19
- BufferComponent,
20
- BufferIntent,
21
- ]
22
-
23
-
24
- @pytest.mark.parametrize("enum_class", DOCUMENTED_ENUMS)
25
- def test_enums_have_docstrings(enum_class):
26
- """Test that all interface.py enums have comprehensive per-member
27
- docstrings
28
-
29
- This test ensures that all enum members have their own
30
- runtime-accessible docstrings, which makes them discoverable via
31
- help(), IDE tooltips, and the __doc__ attribute.
32
-
33
- The enums use the @enum_docstrings decorator which parses source code
34
- to attach docstrings that appear below each member (following PEP 257
35
- attribute docstring conventions).
36
-
37
- See: https://stackoverflow.com/questions/19330460/
38
- how-do-i-put-docstrings-on-enums
39
- """
40
- enum_name = enum_class.__name__
41
-
42
- # Check class has a docstring
43
- assert enum_class.__doc__ is not None, f"{enum_name} must have a class docstring"
44
- assert len(enum_class.__doc__) > 0, f"{enum_name} class docstring must not be empty"
45
-
46
- # Check each member has its own unique docstring
47
- for member in enum_class:
48
- assert member.__doc__ is not None, (
49
- f"{enum_name}.{member.name} must have a docstring. "
50
- f"Add a docstring after the member definition:\n"
51
- f' {member.name} = "{member.value}"\n'
52
- f' """Your documentation here"""'
53
- )
54
- assert len(member.__doc__.strip()) > 0, (
55
- f"{enum_name}.{member.name} docstring must not be empty"
56
- )
57
- # Verify it's not just the class docstring (should be member-specific)
58
- assert member.__doc__ != enum_class.__doc__, (
59
- f"{enum_name}.{member.name} should have its own docstring, "
60
- f"not inherit the class docstring. "
61
- f"Did the @enum_docstrings decorator work?"
62
- )
63
-
64
- # Verify different members have different docstrings
65
- members = list(enum_class)
66
- if len(members) >= 2:
67
- assert members[0].__doc__ != members[1].__doc__, (
68
- f"{enum_name}: Different enum members should have different docstrings"
69
- )
70
-
71
-
72
- @pytest.mark.parametrize("enum_class", DOCUMENTED_ENUMS)
73
- def test_enums_json_schema_includes_member_docstrings(enum_class):
74
- """Test that enum member docstrings appear in Pydantic JSON schemas
75
-
76
- The @enum_docstrings decorator adds __get_pydantic_json_schema__ to
77
- generate oneOf schemas with per-member descriptions. This ensures
78
- enum documentation is available in API schemas, OpenAPI specs, etc.
79
- """
80
- enum_name = enum_class.__name__
81
-
82
- # Create a test model using this enum
83
- TestModel = type(
84
- "TestModel",
85
- (BaseModel,),
86
- {"__annotations__": {"field": enum_class}},
87
- )
88
-
89
- # Get the JSON schema
90
- schema = TestModel.model_json_schema()
91
-
92
- # Check the enum definition exists in $defs
93
- assert "$defs" in schema, f"{enum_name}: Schema missing $defs"
94
- assert enum_name in schema["$defs"], f"{enum_name}: Enum not in $defs"
95
-
96
- enum_schema = schema["$defs"][enum_name]
97
-
98
- # Check oneOf exists with member descriptions
99
- assert "oneOf" in enum_schema, (
100
- f"{enum_name}: JSON schema missing oneOf for member descriptions"
101
- )
102
-
103
- one_of = enum_schema["oneOf"]
104
- assert len(one_of) == len(enum_class), (
105
- f"{enum_name}: oneOf should have {len(enum_class)} entries"
106
- )
107
-
108
- # Verify each member has proper schema entry
109
- for member in enum_class:
110
- matching_entries = [
111
- entry for entry in one_of if entry.get("const") == member.value
112
- ]
113
-
114
- assert len(matching_entries) == 1, (
115
- f"{enum_name}.{member.name}: Should have exactly one oneOf entry"
116
- )
117
-
118
- entry = matching_entries[0]
119
-
120
- # Check required fields
121
- assert "const" in entry, f"{enum_name}.{member.name}: Missing 'const'"
122
- assert "title" in entry, f"{enum_name}.{member.name}: Missing 'title'"
123
- assert "description" in entry, (
124
- f"{enum_name}.{member.name}: Missing 'description'"
125
- )
126
-
127
- # Verify description matches member docstring
128
- assert entry["description"] == member.__doc__, (
129
- f"{enum_name}.{member.name}: Schema description doesn't match "
130
- f"member.__doc__"
131
- )
132
-
133
- # Verify description is not empty and not the class docstring
134
- assert len(entry["description"].strip()) > 0, (
135
- f"{enum_name}.{member.name}: Description should not be empty"
136
- )
137
- assert entry["description"] != enum_class.__doc__, (
138
- f"{enum_name}.{member.name}: Description should be member-specific, "
139
- f"not the class docstring"
140
- )