Skip to content

Decision Point Objects

The ssvc.decision_points package provides a set of decision points for use in SSVC decision functions. Decision points are the basic building blocks of SSVC decision functions. Individual decision points describe a single aspect of the input to a decision function.

Defines the formatting for SSVC Decision Points.

DecisionPoint

Bases: _Registered, _Valued, _SchemaVersioned, _GenericSsvcObject, _Commented, BaseModel

Models a single decision point as a list of values.

Decision points should have the following attributes:

  • name (str): The name of the decision point
  • description (str): A description of the decision point
  • version (str): A semantic version string for the decision point
  • namespace (str): The namespace (a short, unique string): For example, "ssvc" or "cvss" to indicate the source of the decision point
  • key (str): A key (a short, unique string within the namespace) that can be used to identify the decision point in a shorthand way
  • values (tuple): A tuple of DecisionPointValue objects
Source code in src/ssvc/decision_points/base.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
class DecisionPoint(
    _Registered,
    _Valued,
    _SchemaVersioned,
    _GenericSsvcObject,
    _Commented,
    BaseModel,
):
    """
    Models a single decision point as a list of values.

    Decision points should have the following attributes:

    - name (str): The name of the decision point
    - description (str): A description of the decision point
    - version (str): A semantic version string for the decision point
    - namespace (str): The namespace (a short, unique string): For example, "ssvc" or "cvss" to indicate the source of the decision point
    - key (str): A key (a short, unique string within the namespace) that can be used to identify the decision point in a shorthand way
    - values (tuple): A tuple of DecisionPointValue objects
    """
    _schema_version: ClassVar[str] = SCHEMA_VERSION
    schemaVersion: Literal[SCHEMA_VERSION]
    values: tuple[DecisionPointValue, ...]
    model_config = ConfigDict(revalidate_instances="always")

    def __str__(self):
        return FIELD_DELIMITER.join([self.namespace, self.key, self.version])

    @property
    def id(self) -> str:
        f"""
        Return an identity string for the DecisionPoint, combining namespace, key, and version into a global unique identifier.

        Returns:
            str: A string representation of the DecisionPoint in the format "namespace{FIELD_DELIMITER}key{FIELD_DELIMITER}version".
        """
        id_parts = (self.namespace, self.key, self.version)

        return FIELD_DELIMITER.join(id_parts)

    @property
    def value_dict(self) -> dict[str, DecisionPointValue]:
        """
        Return a list of value IDs for the DecisionPoint.

        Returns:
            list: A list of strings, each representing a value ID in the format "namespace:key:version:value".

        """
        value_dict = {}
        for value in self.values:
            value_id = FIELD_DELIMITER.join([self.id, value.key])
            value_dict[value_id] = value
        return value_dict

    @property
    def str(self) -> str:
        """
        Return the DecisionPoint represented as a short string.

        Returns:
            str: A string representation of the DecisionPoint, in the format "namespace:key:version".

        """
        return self.__str__()

    @model_validator(mode="before")
    def _set_schema_version(cls, data: dict) -> dict:
        """
        Set the schema version to the default if not provided.
        """
        if "schemaVersion" not in data:
            data["schemaVersion"] = SCHEMA_VERSION
        return data

    @model_validator(mode="after")
    def _validate_values(self):
        # confirm that value keys are unique
        seen = dict()
        for value in self.values:
            if value.key in seen:
                raise ValueError(
                    f"Duplicate key found in {self.id}: {value.key} ({value.name} and {seen[value.key]})"
                )
            else:
                seen[value.key] = value.name

        # if we got here, all good
        return self

    @property
    def value_summaries(self) -> list[str]:
        """
        Return a list of value summaries.
        """
        return list(self.value_dict.keys())

str property

Return the DecisionPoint represented as a short string.

Returns:

Name Type Description
str str

A string representation of the DecisionPoint, in the format "namespace🔑version".

value_dict property

Return a list of value IDs for the DecisionPoint.

Returns:

Name Type Description
list dict[str, DecisionPointValue]

A list of strings, each representing a value ID in the format "namespace🔑version:value".

value_summaries property

Return a list of value summaries.

DecisionPointValue

Bases: _Commented, _KeyedBaseModel, BaseModel

Models a single value option for a decision point.

Each value should have the following attributes:

  • name (str): A name
  • description (str): A description
  • key (str): A key (a short, unique string) that can be used to identify the value in a shorthand way
  • _comment (str): An optional comment that will be included in the object.
Source code in src/ssvc/decision_points/base.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class DecisionPointValue(_Commented, _KeyedBaseModel, BaseModel):
    """
    Models a single value option for a decision point.

    Each value should have the following attributes:

    - name (str): A name
    - description (str): A description
    - key (str): A key (a short, unique string) that can be used to identify the value in a shorthand way
    - _comment (str): An optional comment that will be included in the object.
    """

    def __str__(self):
        return self.name

Provides helper functions for working with SSVC decision points.

dp_diff(dp1, dp2)

Compares two decision points and returns a list of differences.

Parameters:

Name Type Description Default
dp1 DecisionPoint

the first decision point to compare

required
dp2 DecisionPoint

the second decision point to compare

required

Returns:

Type Description
list[str]

A list of differences between the two decision points

Source code in src/ssvc/decision_points/helpers.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def dp_diff(dp1: DecisionPoint, dp2: DecisionPoint) -> list[str]:
    """
    Compares two decision points and returns a list of differences.

    Args:
        dp1: the first decision point to compare
        dp2: the second decision point to compare

    Returns:
        A list of differences between the two decision points
    """

    major = False
    maybe_major = False
    minor = False
    maybe_minor = False
    patch = False

    diffs = []

    name_change = False
    desc_change = False
    key_change = False

    # did the name change?
    if dp1.name != dp2.name:
        name_change = True

        # was it a big change?
        from thefuzz import fuzz

        # fuzz ratio is 100 when exact match, 0 when no match
        if fuzz.ratio(dp1.name, dp2.name) < 50:
            diffs.append(f"(minor) {dp2.name} name changed from {dp1.name}")
            minor = True
        else:
            diffs.append(
                f"(patch / maybe minor) {dp2.name} name changed from {dp1.name}"
            )
            # It was a small change so maybe minor but probably patch
            patch = True
            maybe_minor = True

    # did the description change?
    desc1 = dp1.definition.strip()
    desc2 = dp2.definition.strip()

    if desc1 != desc2:
        diffs.append(f"(patch) {dp2.name} v{dp2.version} description changed")
        patch = True
        desc_change = True
    else:
        diffs.append(f"{dp2.name} v{dp2.version} description did not change")

    # did the key change?
    key1 = dp1.key.strip()
    key2 = dp2.key.strip()

    if key1 != key2:
        diffs.append(f"(major) {dp2.name} v{dp2.version} key changed")
        major = True
        key_change = True
    else:
        diffs.append(f"{dp2.name} v{dp2.version} key did not change")

    maybe_new_obj = all([name_change, desc_change, key_change])

    # did the version change?
    version1 = dp1.version.strip()
    version2 = dp2.version.strip()

    if version1 != version2:
        diffs.append(f"{dp2.name} v{version2} version changed from {version1}")
    else:
        diffs.append(f"{dp2.name} version did not change")

    # did the values change?
    # did the value names change?
    dp1_names = set([v.name for v in dp1.values])
    dp2_names = set([v.name for v in dp2.values])

    intersection = dp1_names.intersection(dp2_names)

    if dp1_names == dp2_names:
        diffs.append(f"{dp2.name} v{dp2.version} value names did not change")

    # names removed from dp1 in dp2:
    for name in dp1_names.difference(dp2_names):
        diffs.append(f"(major) {dp2.name} v{dp2.version} removes value {name}")
        major = True

    for name in dp2_names.difference(dp1_names):
        diffs.append(
            f"(major or minor) {dp2.name} v{dp2.version} adds value {name}"
        )
        maybe_major = True
        maybe_minor = True

    # did the value keys change?
    for name in intersection:
        v1 = {
            value["name"]: value["key"] for value in dp1.model_dump()["values"]
        }
        v1 = v1[name]

        v2 = {
            value["name"]: value["key"] for value in dp2.model_dump()["values"]
        }
        v2 = v2[name]

        if v1 != v2:
            diffs.append(
                f"(minor) {dp2.name} v{dp2.version} value {name} key changed"
            )
            minor = True
        else:
            diffs.append(
                f"{dp2.name} v{dp2.version} value {name} key did not change"
            )

    # did the value descriptions change?
    for name in intersection:
        v1 = {
            value["name"]: value["definition"]
            for value in dp1.model_dump()["values"]
        }
        v1 = v1[name]

        v2 = {
            value["name"]: value["definition"]
            for value in dp2.model_dump()["values"]
        }
        v2 = v2[name]

        if v1 != v2:
            diffs.append(
                f"(patch) {dp2.name} v{dp2.version} value {name} description changed"
            )
            patch = True
        else:
            diffs.append(
                f"{dp2.name} v{dp2.version} value {name} description did not change"
            )

    v1 = semver.VersionInfo.parse(dp1.version)
    v2 = semver.VersionInfo.parse(dp2.version)

    if major:
        diffs.append(f"{dp2.name} v{dp2.version} appears to be a major change")
        expected = v1.bump_major()
        if v2 != expected:
            diffs.append(
                f"Expected version to be bumped to {expected}, but was bumped to {v2}"
            )
    elif minor:
        diffs.append(f"{dp2.name} v{dp2.version} appears to be a minor change")
        expected = v1.bump_minor()
        if v2 != expected:
            diffs.append(
                f"Expected version to be bumped to {expected}, but was bumped to {v2}"
            )
    elif patch and not any([maybe_minor, maybe_major]):
        diffs.append(f"{dp2.name} v{dp2.version} appears to be a patch change")
        expected = v1.bump_patch()
        if v2 != expected:
            diffs.append(
                f"Expected version to be bumped to {expected}, but was bumped to {v2}"
            )

    if maybe_new_obj:
        diffs.append(
            f"(maybe_new_obj) {dp2.name} v{dp2.version} changed name, description, and key. Potentially new object "
            f"depending on context."
        )

    if not major:
        check_possible = False
        possible1 = v1.bump_major()
        possible2 = v1.bump_minor()
        if maybe_major:
            diffs.append(
                f"{dp2.name} v{dp2.version} could be a major change ({v1} -> {possible1}) depending on context"
            )
            check_possible = True
        if maybe_minor:
            diffs.append(
                f"{dp2.name} v{dp2.version} could be a minor change ({v1} -> {possible2}) depending on context"
            )
            check_possible = True
        # did one of possible1 or possible2 match v2?
        if check_possible and v2 not in [possible1, possible2]:
            diffs.append(
                f"Expected version to be bumped to {possible1} or {possible2}, but was bumped to {v2}"
            )

    if not any([major, minor, patch, maybe_major, maybe_minor]):
        diffs.append(f"{dp2.name} v{dp2.version} appears to be a no-op change")

    return diffs

print_versions_and_diffs(versions)

Prints the json representation of each version and then shows the diffs between each version.

Parameters:

Name Type Description Default
versions Sequence[DecisionPoint]

a list of decision point versions

required

Returns:

Type Description
None

None

Source code in src/ssvc/decision_points/helpers.py
244
245
246
247
248
249
250
251
252
253
254
255
256
def print_versions_and_diffs(versions: Sequence[DecisionPoint]) -> None:
    """
    Prints the json representation of each version and then shows the diffs between each version.

    Args:
        versions: a list of decision point versions

    Returns:
        None
    """
    for version in versions:
        print(version.model_dump_json(indent=2))
    show_diffs(versions)