Skip to content

Fields

list (and exists, get_by_name, which use it) reads field metadata through V2; create and delete use V1.

Service for managing custom fields.

Use V2 /fields endpoints for reading field metadata. Use V1 for creating/deleting fields.

Source code in affinity/services/v1_only.py
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
class FieldService:
    """
    Service for managing custom fields.

    Use V2 /fields endpoints for reading field metadata.
    Use V1 for creating/deleting fields.
    """

    def __init__(self, client: HTTPClient):
        self._client = client

    def list(
        self,
        *,
        list_id: ListId | None = None,
        entity_type: EntityType | None = None,
        skip_cache: bool = False,
    ) -> list[FieldMetadata]:
        """
        Get field metadata.

        Results are cached for 5 minutes when caching is enabled on the client.

        Args:
            list_id: Filter to fields for a specific list
            entity_type: Filter to fields for a specific entity type
            skip_cache: When True, bypass the 5-minute cache (useful when
                resolving a field that may have been added recently).

        Returns:
            List of field metadata
        """
        params: dict[str, Any] = {}
        if list_id is not None:
            params["list_id"] = int(list_id)
        if entity_type is not None:
            params["entity_type"] = int(entity_type)

        get_kwargs: dict[str, Any] = {"v1": True}
        if not skip_cache:
            list_key = "all" if list_id is None else int(list_id)
            type_key = "all" if entity_type is None else int(entity_type)
            get_kwargs["cache_key"] = f"field:v1_list_{list_key}:type_{type_key}"
            get_kwargs["cache_ttl"] = 300

        data = self._client.get(
            "/fields",
            params=params or None,
            **get_kwargs,
        )
        items = data.get("data", [])
        if not isinstance(items, list):
            items = []
        return [FieldMetadata.model_validate(f) for f in items]

    def create(self, data: FieldCreate) -> FieldMetadata:
        """Create a custom field."""
        value_type_code = to_v1_value_type_code(value_type=data.value_type, raw=None)
        if value_type_code is None:
            raise ValueError(f"Field value_type has no V1 numeric mapping: {data.value_type!s}")
        payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
        payload["entity_type"] = int(data.entity_type)
        payload["value_type"] = value_type_code
        for key in ("allows_multiple", "is_list_specific", "is_required"):
            if not payload.get(key):
                payload.pop(key, None)

        result = self._client.post("/fields", json=payload, v1=True)

        # Invalidate field caches
        if self._client.cache:
            self._client.cache.invalidate_prefix("field")
            self._client.cache.invalidate_prefix("list_")
            self._client.cache.invalidate_prefix("person_fields")
            self._client.cache.invalidate_prefix("company_fields")

        return FieldMetadata.model_validate(result)

    def delete(self, field_id: FieldId) -> bool:
        """
        Delete a custom field (V1 API).

        Note: V1 deletes require numeric field IDs. The SDK accepts V2-style
        `field-<digits>` IDs and converts them; enriched/relationship-intelligence
        IDs are not supported.
        """
        numeric_id = field_id_to_v1_numeric(field_id)
        result = self._client.delete(f"/fields/{numeric_id}", v1=True)

        # Invalidate field caches
        if self._client.cache:
            self._client.cache.invalidate_prefix("field")
            self._client.cache.invalidate_prefix("list_")
            self._client.cache.invalidate_prefix("person_fields")
            self._client.cache.invalidate_prefix("company_fields")

        return bool(result.get("success", True))

    def exists(self, field_id: AnyFieldId) -> bool:
        """
        Check if a field exists.

        Useful for validation before setting field values.

        Note: This fetches all fields and checks locally. If your code calls
        exists() frequently in a loop, consider caching the result of fields.list()
        yourself.

        Args:
            field_id: The field ID to check

        Returns:
            True if the field exists, False otherwise

        Example:
            if client.fields.exists(FieldId("field-123")):
                client.field_values.create(...)
        """
        target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
        fields = self.list()
        return any(f.id == target_id for f in fields)

    def get_by_name(self, name: str) -> FieldMetadata | None:
        """
        Find a field by its display name.

        Uses case-insensitive matching (casefold for i18n support).

        Note: This fetches all fields and searches locally. If your code calls
        get_by_name() frequently in a loop, consider caching the result of
        fields.list() yourself.

        Args:
            name: The field display name to search for

        Returns:
            FieldMetadata if found, None otherwise

        Example:
            field = client.fields.get_by_name("Primary Email Status")
            if field:
                fv = client.field_values.get_for_entity(field.id, person_id=pid)
        """
        fields = self.list()
        name_folded = name.strip().casefold()  # Strip whitespace, then casefold for i18n
        for field in fields:
            if field.name.casefold() == name_folded:
                return field
        return None

create(data: FieldCreate) -> FieldMetadata

Create a custom field.

Source code in affinity/services/v1_only.py
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
def create(self, data: FieldCreate) -> FieldMetadata:
    """Create a custom field."""
    value_type_code = to_v1_value_type_code(value_type=data.value_type, raw=None)
    if value_type_code is None:
        raise ValueError(f"Field value_type has no V1 numeric mapping: {data.value_type!s}")
    payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
    payload["entity_type"] = int(data.entity_type)
    payload["value_type"] = value_type_code
    for key in ("allows_multiple", "is_list_specific", "is_required"):
        if not payload.get(key):
            payload.pop(key, None)

    result = self._client.post("/fields", json=payload, v1=True)

    # Invalidate field caches
    if self._client.cache:
        self._client.cache.invalidate_prefix("field")
        self._client.cache.invalidate_prefix("list_")
        self._client.cache.invalidate_prefix("person_fields")
        self._client.cache.invalidate_prefix("company_fields")

    return FieldMetadata.model_validate(result)

delete(field_id: FieldId) -> bool

Delete a custom field (V1 API).

Note: V1 deletes require numeric field IDs. The SDK accepts V2-style field-<digits> IDs and converts them; enriched/relationship-intelligence IDs are not supported.

Source code in affinity/services/v1_only.py
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
def delete(self, field_id: FieldId) -> bool:
    """
    Delete a custom field (V1 API).

    Note: V1 deletes require numeric field IDs. The SDK accepts V2-style
    `field-<digits>` IDs and converts them; enriched/relationship-intelligence
    IDs are not supported.
    """
    numeric_id = field_id_to_v1_numeric(field_id)
    result = self._client.delete(f"/fields/{numeric_id}", v1=True)

    # Invalidate field caches
    if self._client.cache:
        self._client.cache.invalidate_prefix("field")
        self._client.cache.invalidate_prefix("list_")
        self._client.cache.invalidate_prefix("person_fields")
        self._client.cache.invalidate_prefix("company_fields")

    return bool(result.get("success", True))

exists(field_id: AnyFieldId) -> bool

Check if a field exists.

Useful for validation before setting field values.

Note: This fetches all fields and checks locally. If your code calls exists() frequently in a loop, consider caching the result of fields.list() yourself.

Parameters:

Name Type Description Default
field_id AnyFieldId

The field ID to check

required

Returns:

Type Description
bool

True if the field exists, False otherwise

Example

if client.fields.exists(FieldId("field-123")): client.field_values.create(...)

Source code in affinity/services/v1_only.py
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
def exists(self, field_id: AnyFieldId) -> bool:
    """
    Check if a field exists.

    Useful for validation before setting field values.

    Note: This fetches all fields and checks locally. If your code calls
    exists() frequently in a loop, consider caching the result of fields.list()
    yourself.

    Args:
        field_id: The field ID to check

    Returns:
        True if the field exists, False otherwise

    Example:
        if client.fields.exists(FieldId("field-123")):
            client.field_values.create(...)
    """
    target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
    fields = self.list()
    return any(f.id == target_id for f in fields)

get_by_name(name: str) -> FieldMetadata | None

Find a field by its display name.

Uses case-insensitive matching (casefold for i18n support).

Note: This fetches all fields and searches locally. If your code calls get_by_name() frequently in a loop, consider caching the result of fields.list() yourself.

Parameters:

Name Type Description Default
name str

The field display name to search for

required

Returns:

Type Description
FieldMetadata | None

FieldMetadata if found, None otherwise

Example

field = client.fields.get_by_name("Primary Email Status") if field: fv = client.field_values.get_for_entity(field.id, person_id=pid)

Source code in affinity/services/v1_only.py
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
def get_by_name(self, name: str) -> FieldMetadata | None:
    """
    Find a field by its display name.

    Uses case-insensitive matching (casefold for i18n support).

    Note: This fetches all fields and searches locally. If your code calls
    get_by_name() frequently in a loop, consider caching the result of
    fields.list() yourself.

    Args:
        name: The field display name to search for

    Returns:
        FieldMetadata if found, None otherwise

    Example:
        field = client.fields.get_by_name("Primary Email Status")
        if field:
            fv = client.field_values.get_for_entity(field.id, person_id=pid)
    """
    fields = self.list()
    name_folded = name.strip().casefold()  # Strip whitespace, then casefold for i18n
    for field in fields:
        if field.name.casefold() == name_folded:
            return field
    return None

list(*, list_id: ListId | None = None, entity_type: EntityType | None = None, skip_cache: bool = False) -> list[FieldMetadata]

Get field metadata.

Results are cached for 5 minutes when caching is enabled on the client.

Parameters:

Name Type Description Default
list_id ListId | None

Filter to fields for a specific list

None
entity_type EntityType | None

Filter to fields for a specific entity type

None
skip_cache bool

When True, bypass the 5-minute cache (useful when resolving a field that may have been added recently).

False

Returns:

Type Description
list[FieldMetadata]

List of field metadata

Source code in affinity/services/v1_only.py
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
def list(
    self,
    *,
    list_id: ListId | None = None,
    entity_type: EntityType | None = None,
    skip_cache: bool = False,
) -> list[FieldMetadata]:
    """
    Get field metadata.

    Results are cached for 5 minutes when caching is enabled on the client.

    Args:
        list_id: Filter to fields for a specific list
        entity_type: Filter to fields for a specific entity type
        skip_cache: When True, bypass the 5-minute cache (useful when
            resolving a field that may have been added recently).

    Returns:
        List of field metadata
    """
    params: dict[str, Any] = {}
    if list_id is not None:
        params["list_id"] = int(list_id)
    if entity_type is not None:
        params["entity_type"] = int(entity_type)

    get_kwargs: dict[str, Any] = {"v1": True}
    if not skip_cache:
        list_key = "all" if list_id is None else int(list_id)
        type_key = "all" if entity_type is None else int(entity_type)
        get_kwargs["cache_key"] = f"field:v1_list_{list_key}:type_{type_key}"
        get_kwargs["cache_ttl"] = 300

    data = self._client.get(
        "/fields",
        params=params or None,
        **get_kwargs,
    )
    items = data.get("data", [])
    if not isinstance(items, list):
        items = []
    return [FieldMetadata.model_validate(f) for f in items]