Skip to content

Lists

client.lists.entries(list_id).find(...), iter_find(...) and find_body(...) filter and sort a list's entries on Affinity's side (V2 search, beta) and return the same entries as entries(list_id).list(). They work like the companies methods and need the "Export data from Lists" permission.

from affinity.criteria import Condition, Sort
from affinity.types import ListId

page = client.lists.entries(ListId(123)).find(
    Condition("field-456", "is-within-the-last", (30, "day")),
    sort=Sort("field-789", "desc"),
    limit=100,
)

Service for managing lists.

Lists are spreadsheet-like collections of people, companies, or opportunities.

Source code in affinity/services/lists.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
class ListService:
    """
    Service for managing lists.

    Lists are spreadsheet-like collections of people, companies, or opportunities.
    """

    def __init__(self, client: HTTPClient):
        self._client = client
        self._resolve_cache: dict[tuple[str, ListType | None], AffinityList | None] = {}
        # (timestamp, size), shared by every list service on this client
        self._size_cache: dict[int, tuple[float, int]] = _size_cache_for(client)
        # Fields, dropdown options and refused searches for filtered reads, per list
        self._search_cache = SearchCache()

    def entries(self, list_id: ListId) -> ListEntryService:
        """
        Get a ListEntryService for a specific list.

        This is the explicit path for retrieving "full row" data via list entries.
        """
        return ListEntryService(self._client, list_id, self._search_cache)

    # =========================================================================
    # List Operations (V2 for read, V1 for write)
    # =========================================================================

    def list(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
        term: str | None = None,
    ) -> PaginatedResponse[AffinityList]:
        """
        Get all lists accessible to you.

        Args:
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).
            term: Only lists whose name contains this text (case-insensitive substring,
                matched by Affinity). Kept across pages by the cursor.

        Returns:
            Paginated list of lists (without field metadata)
        """
        term = term or None
        if cursor is not None:
            if limit is not None or term is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            data = self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            if term is not None:
                params["term"] = term
            data = self._client.get("/lists", params=params or None)

        return PaginatedResponse[AffinityList](
            data=[
                _safe_model_validate(AffinityList, list_item) for list_item in data.get("data", [])
            ],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    def get_first(self) -> AffinityList | None:
        """Get the first list, or None if no lists exist."""
        page = self.list(limit=1)
        return page.data[0] if page.data else None

    def pages(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
        term: str | None = None,
    ) -> Iterator[PaginatedResponse[AffinityList]]:
        """
        Iterate list pages (not items), yielding `PaginatedResponse[AffinityList]`.

        This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
        ``term`` keeps only lists whose name contains it (case-insensitive, matched by Affinity).
        """
        term = term or None
        if cursor is not None and (limit is not None or term is not None):
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        guard = _CursorGuard(cursor)
        page = self.list(limit=limit, term=term) if cursor is None else self.list(cursor=cursor)
        while True:
            yield page
            # Stop at the last page, or when paging can't progress (see _CursorGuard).
            if guard.last_page(requested_cursor, page):
                return
            requested_cursor = page.next_cursor
            page = self.list(cursor=requested_cursor)

    def all(self) -> Iterator[AffinityList]:
        """Iterate through all accessible lists."""

        def fetch_page(next_url: str | None) -> PaginatedResponse[AffinityList]:
            if next_url:
                data = self._client.get_url(next_url)
                return PaginatedResponse[AffinityList](
                    data=[
                        _safe_model_validate(AffinityList, list_item)
                        for list_item in data.get("data", [])
                    ],
                    pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
                )
            return self.list()

        return PageIterator(fetch_page)

    def iter(self) -> Iterator[AffinityList]:
        """
        Auto-paginate all lists.

        Alias for `all()` (FR-006 public contract).
        """
        return self.all()

    def get(self, list_id: ListId) -> AffinityList:
        """
        Get a single list by ID (V2).

        ``fields`` is not filled: use :meth:`get_fields`. For the number of entries use
        :meth:`get_size`.
        """
        data = self._client.get(f"/lists/{int(list_id)}")
        return _safe_model_validate(AffinityList, data)

    def get_size(self, list_id: ListId, *, force: bool = False) -> int:
        """
        Get accurate list size. Uses V1 API, cached for 5 minutes.

        Adding or removing a row through this client (``entries(list_id).add_*`` / ``delete``,
        ``opportunities.create``) drops the cached size; changes made elsewhere show after the
        cache expires, or with ``force=True``.

        Args:
            list_id: The list ID.
            force: If True, bypass cache and fetch fresh value from API.

        Note: The V2 API's listSize field is unreliable (often returns 0 for
        non-empty lists). This method uses the V1 API which returns accurate values.
        """
        if not force and list_id in self._size_cache:
            cached_at, size = self._size_cache[list_id]
            if time.monotonic() - cached_at < _SIZE_CACHE_TTL:
                return size

        # V2 has no list size: read it from V1
        data = self._client.get(f"/lists/{int(list_id)}", v1=True)
        size = _safe_model_validate(AffinityList, data)._list_size_hint
        self._size_cache[list_id] = (time.monotonic(), size)
        return size

    def resolve(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> AffinityList | None:
        """
        Find a single list by name (case-insensitive exact match; optionally filtered by type).

        Notes:
        - Affinity narrows the lists by name first (``term``); the exact comparison is made
          here. If that finds nothing, every list is checked (see `resolve_all()`).
        - Matches are cached in-memory on this service instance (a miss is not). If you call
          this frequently, reuse the client, or persist the resolved `ListId` in your own
          configuration.

        If multiple matches exist, returns the first match in server-provided order.
        """
        key = (name.lower(), list_type)
        if key in self._resolve_cache:
            return self._resolve_cache[key]

        matches = self.resolve_all(name=name, list_type=list_type)
        found = matches[0] if matches else None
        if found is not None:  # a miss isn't cached: the list may be created later
            self._resolve_cache[key] = found
        return found

    def resolve_all(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> builtins.list[AffinityList]:
        """
        Find all lists matching a name (case-insensitive exact match; optionally by type).

        Notes:
        - For an ASCII name, Affinity first narrows the lists by name (``term``, a
          case-insensitive substring match) and the exact comparison is made here. If that
          yields no match, every list is checked, so a difference in how Affinity folds case
          or whitespace can't hide a list. Non-ASCII names always check every list.
        - Unlike `resolve()`, this does not cache results.
        """
        term = name.strip()
        if term and term.isascii():
            candidates = (item for page in self.pages(term=term) for item in page.data)
            matches = _exact_name_matches(candidates, name, list_type)
            if matches:
                return matches
        return _exact_name_matches(self.all(), name, list_type)

    def create(self, data: ListCreate) -> AffinityList:
        """
        Create a list (V2); the caller becomes its creator and owner.

        A public list needs the "Share accessible Lists globally" permission. V2 can't set
        ``owner_id`` or ``additional_permissions``: with either, the list is created through V1.
        """
        if _uses_v1_create(data):
            result = self._client.post("/lists", json=_v1_list_create_payload(data), v1=True)
        else:
            result = self._client.post("/lists", json=_list_create_body(data))

        # Invalidate cache
        if self._client.cache:
            self._client.cache.invalidate_prefix("list")
        self._resolve_cache.clear()

        return _safe_model_validate(AffinityList, result)

    # =========================================================================
    # Field Operations
    # =========================================================================

    def get_fields(
        self,
        list_id: ListId,
        *,
        filter: str | None = None,
        includes: Sequence[str] | None = None,
    ) -> builtins.list[FieldMetadata]:
        """
        Get fields (columns) for a list.

        Includes list-specific, global, enriched, and relationship intelligence fields.
        Cached for performance.

        Args:
            filter: Filter on the field name: ``name="Location"`` (exact) or ``name=~Loc``
                (substring); both case-sensitive. Combine clauses with ``|``. Only ``name``
                is filterable; anything else is rejected with 400.
            includes: Extra properties per field: ``"filterability"``, ``"sortability"``
                (read them via ``is_filterable`` / ``is_sortable``).
        """
        params, cache_key = build_fields_query(
            f"list_{list_id}_fields:",
            filter=filter,
            includes=includes,
        )

        data = self._client.get_all_pages(
            f"/lists/{list_id}/fields",
            params=params or None,
            cache_key=cache_key,
            cache_ttl=300,
        )

        return [_safe_model_validate(FieldMetadata, f) for f in data.get("data", [])]

    # =========================================================================
    # Dropdown options of list fields
    # =========================================================================

    def get_field_dropdown_options(
        self, list_id: ListId, field_id: AnyFieldId, *, with_status_types: bool = False
    ) -> builtins.list[DropdownOption]:
        """All options of a list field's dropdown, ranked-dropdown or status field (every page).

        Older API versions report status options as ``ranked-dropdown`` without
        ``status_category``. ``with_status_types=True`` reads with API version
        {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (as the option writes do) to get the real
        ``status-dropdown`` type; a client pinned to an older version then raises
        ``ApiVersionTooOldError``.
        """
        data = self._client.get_all_pages(
            options_path(field_id, list_id=list_id, entity=None),
            params={"limit": 100},
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION if with_status_types else None,
        )
        return parse_options(data)

    def get_field_dropdown_option(
        self, list_id: ListId, field_id: AnyFieldId, option_id: int
    ) -> DropdownOption:
        """One option of a list field."""
        return DropdownOption.model_validate(
            self._client.get(option_path(list_id, field_id, option_id))
        )

    def create_field_dropdown_option(
        self,
        list_id: ListId,
        field_id: AnyFieldId,
        *,
        option_type: DropdownOptionType,
        text: str,
        rank: int | None = None,
        color: DropdownOptionColorName | None = None,
        status_category: DropdownStatusCategory | None = None,
        win_rate: int | None = None,
    ) -> DropdownOption:
        """Add an option to a list field.

        ``option_type`` must match the field: ``"dropdown"`` (text only), ``"ranked-dropdown"``
        (``rank`` and ``color`` required) or ``"status-dropdown"`` (``rank``, ``color`` and
        ``status_category`` required; ``win_rate`` 0-100 only for ``"open"``). Needs Affinity API
        version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (sent for this call).
        """
        body = create_body(
            option_type=option_type,
            text=text,
            rank=rank,
            color=color,
            status_category=status_category,
            win_rate=win_rate,
        )
        data = self._client.post(
            options_path(field_id, list_id=list_id, entity=None),
            json=body,
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
        return DropdownOption.model_validate(data)

    def update_field_dropdown_option(
        self,
        list_id: ListId,
        field_id: AnyFieldId,
        option_id: int,
        *,
        text: str | None = None,
        rank: int | None = None,
        color: DropdownOptionColorName | None = None,
        status_category: DropdownStatusCategory | None = None,
        win_rate: int | None = None,
    ) -> DropdownOption:
        """Change an option of a list field (only what you pass).

        A ``dropdown`` option can change its text; ``ranked-dropdown`` also rank and color;
        ``status-dropdown`` also status category and win rate. Renaming changes the value shown
        on every entry that has this option. Returns the option as read back afterwards.
        """
        body = update_body(
            text=text,
            rank=rank,
            color=color,
            status_category=status_category,
            win_rate=win_rate,
        )
        self._client.post(
            option_path(list_id, field_id, option_id),
            json=body,
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
        # The API answers 204 with no body: read the option back
        return DropdownOption.model_validate(
            self._client.get(
                option_path(list_id, field_id, option_id),
                min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
            )
        )

    def delete_field_dropdown_option(
        self, list_id: ListId, field_id: AnyFieldId, option_id: int
    ) -> bool:
        """Delete an option of a list field. **Cannot be undone.**

        Affinity also clears this field on every list entry currently set to the option; those
        values cannot be recovered.
        """
        self._client.delete(
            option_path(list_id, field_id, option_id),
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
        return True

    # =========================================================================
    # Saved View Operations
    # =========================================================================

    def get_saved_views(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[SavedView]:
        """
        Get saved views for a list.

        Args:
            list_id: List id for the initial request.
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).
        """
        if cursor is not None:
            if limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            cursor_list_id = _saved_views_list_id_from_cursor(cursor)
            if cursor_list_id is not None and int(list_id) != cursor_list_id:
                raise ValueError(
                    f"Cursor does not match list_id: cursor is for list {cursor_list_id}, "
                    f"requested list_id is {int(list_id)}"
                )
            data = self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            data = self._client.get(f"/lists/{list_id}/saved-views", params=params or None)

        return PaginatedResponse[SavedView](
            data=[_safe_model_validate(SavedView, v) for v in data.get("data", [])],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    def saved_views_pages(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> Iterator[PaginatedResponse[SavedView]]:
        """Iterate saved view pages, yielding `PaginatedResponse[SavedView]`."""
        if cursor is not None and limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        guard = _CursorGuard(cursor)
        page = (
            self.get_saved_views(list_id, limit=limit)
            if cursor is None
            else self.get_saved_views(list_id, cursor=cursor)
        )
        while True:
            yield page
            # Stop at the last page, or when paging can't progress (see _CursorGuard).
            if guard.last_page(requested_cursor, page):
                return
            requested_cursor = page.next_cursor
            page = self.get_saved_views(list_id, cursor=requested_cursor)

    def saved_views_all(self, list_id: ListId) -> Iterator[SavedView]:
        """Iterate all saved views for a list."""
        for page in self.saved_views_pages(list_id):
            yield from page.data

    def get_saved_view(self, list_id: ListId, view_id: SavedViewId) -> SavedView:
        """Get a single saved view."""
        data = self._client.get(f"/lists/{list_id}/saved-views/{view_id}")
        return _safe_model_validate(SavedView, data)

all() -> Iterator[AffinityList]

Iterate through all accessible lists.

Source code in affinity/services/lists.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def all(self) -> Iterator[AffinityList]:
    """Iterate through all accessible lists."""

    def fetch_page(next_url: str | None) -> PaginatedResponse[AffinityList]:
        if next_url:
            data = self._client.get_url(next_url)
            return PaginatedResponse[AffinityList](
                data=[
                    _safe_model_validate(AffinityList, list_item)
                    for list_item in data.get("data", [])
                ],
                pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
            )
        return self.list()

    return PageIterator(fetch_page)

create(data: ListCreate) -> AffinityList

Create a list (V2); the caller becomes its creator and owner.

A public list needs the "Share accessible Lists globally" permission. V2 can't set owner_id or additional_permissions: with either, the list is created through V1.

Source code in affinity/services/lists.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
def create(self, data: ListCreate) -> AffinityList:
    """
    Create a list (V2); the caller becomes its creator and owner.

    A public list needs the "Share accessible Lists globally" permission. V2 can't set
    ``owner_id`` or ``additional_permissions``: with either, the list is created through V1.
    """
    if _uses_v1_create(data):
        result = self._client.post("/lists", json=_v1_list_create_payload(data), v1=True)
    else:
        result = self._client.post("/lists", json=_list_create_body(data))

    # Invalidate cache
    if self._client.cache:
        self._client.cache.invalidate_prefix("list")
    self._resolve_cache.clear()

    return _safe_model_validate(AffinityList, result)

create_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, *, option_type: DropdownOptionType, text: str, rank: int | None = None, color: DropdownOptionColorName | None = None, status_category: DropdownStatusCategory | None = None, win_rate: int | None = None) -> DropdownOption

Add an option to a list field.

option_type must match the field: "dropdown" (text only), "ranked-dropdown" (rank and color required) or "status-dropdown" (rank, color and status_category required; win_rate 0-100 only for "open"). Needs Affinity API version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (sent for this call).

Source code in affinity/services/lists.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def create_field_dropdown_option(
    self,
    list_id: ListId,
    field_id: AnyFieldId,
    *,
    option_type: DropdownOptionType,
    text: str,
    rank: int | None = None,
    color: DropdownOptionColorName | None = None,
    status_category: DropdownStatusCategory | None = None,
    win_rate: int | None = None,
) -> DropdownOption:
    """Add an option to a list field.

    ``option_type`` must match the field: ``"dropdown"`` (text only), ``"ranked-dropdown"``
    (``rank`` and ``color`` required) or ``"status-dropdown"`` (``rank``, ``color`` and
    ``status_category`` required; ``win_rate`` 0-100 only for ``"open"``). Needs Affinity API
    version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (sent for this call).
    """
    body = create_body(
        option_type=option_type,
        text=text,
        rank=rank,
        color=color,
        status_category=status_category,
        win_rate=win_rate,
    )
    data = self._client.post(
        options_path(field_id, list_id=list_id, entity=None),
        json=body,
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
    )
    return DropdownOption.model_validate(data)

delete_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, option_id: int) -> bool

Delete an option of a list field. Cannot be undone.

Affinity also clears this field on every list entry currently set to the option; those values cannot be recovered.

Source code in affinity/services/lists.py
615
616
617
618
619
620
621
622
623
624
625
626
627
def delete_field_dropdown_option(
    self, list_id: ListId, field_id: AnyFieldId, option_id: int
) -> bool:
    """Delete an option of a list field. **Cannot be undone.**

    Affinity also clears this field on every list entry currently set to the option; those
    values cannot be recovered.
    """
    self._client.delete(
        option_path(list_id, field_id, option_id),
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
    )
    return True

entries(list_id: ListId) -> ListEntryService

Get a ListEntryService for a specific list.

This is the explicit path for retrieving "full row" data via list entries.

Source code in affinity/services/lists.py
250
251
252
253
254
255
256
def entries(self, list_id: ListId) -> ListEntryService:
    """
    Get a ListEntryService for a specific list.

    This is the explicit path for retrieving "full row" data via list entries.
    """
    return ListEntryService(self._client, list_id, self._search_cache)

get(list_id: ListId) -> AffinityList

Get a single list by ID (V2).

fields is not filled: use :meth:get_fields. For the number of entries use :meth:get_size.

Source code in affinity/services/lists.py
367
368
369
370
371
372
373
374
375
def get(self, list_id: ListId) -> AffinityList:
    """
    Get a single list by ID (V2).

    ``fields`` is not filled: use :meth:`get_fields`. For the number of entries use
    :meth:`get_size`.
    """
    data = self._client.get(f"/lists/{int(list_id)}")
    return _safe_model_validate(AffinityList, data)

get_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, option_id: int) -> DropdownOption

One option of a list field.

Source code in affinity/services/lists.py
535
536
537
538
539
540
541
def get_field_dropdown_option(
    self, list_id: ListId, field_id: AnyFieldId, option_id: int
) -> DropdownOption:
    """One option of a list field."""
    return DropdownOption.model_validate(
        self._client.get(option_path(list_id, field_id, option_id))
    )

get_field_dropdown_options(list_id: ListId, field_id: AnyFieldId, *, with_status_types: bool = False) -> builtins.list[DropdownOption]

All options of a list field's dropdown, ranked-dropdown or status field (every page).

Older API versions report status options as ranked-dropdown without status_category. with_status_types=True reads with API version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (as the option writes do) to get the real status-dropdown type; a client pinned to an older version then raises ApiVersionTooOldError.

Source code in affinity/services/lists.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def get_field_dropdown_options(
    self, list_id: ListId, field_id: AnyFieldId, *, with_status_types: bool = False
) -> builtins.list[DropdownOption]:
    """All options of a list field's dropdown, ranked-dropdown or status field (every page).

    Older API versions report status options as ``ranked-dropdown`` without
    ``status_category``. ``with_status_types=True`` reads with API version
    {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (as the option writes do) to get the real
    ``status-dropdown`` type; a client pinned to an older version then raises
    ``ApiVersionTooOldError``.
    """
    data = self._client.get_all_pages(
        options_path(field_id, list_id=list_id, entity=None),
        params={"limit": 100},
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION if with_status_types else None,
    )
    return parse_options(data)

get_fields(list_id: ListId, *, filter: str | None = None, includes: Sequence[str] | None = None) -> builtins.list[FieldMetadata]

Get fields (columns) for a list.

Includes list-specific, global, enriched, and relationship intelligence fields. Cached for performance.

Parameters:

Name Type Description Default
filter str | None

Filter on the field name: name="Location" (exact) or name=~Loc (substring); both case-sensitive. Combine clauses with |. Only name is filterable; anything else is rejected with 400.

None
includes Sequence[str] | None

Extra properties per field: "filterability", "sortability" (read them via is_filterable / is_sortable).

None
Source code in affinity/services/lists.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
def get_fields(
    self,
    list_id: ListId,
    *,
    filter: str | None = None,
    includes: Sequence[str] | None = None,
) -> builtins.list[FieldMetadata]:
    """
    Get fields (columns) for a list.

    Includes list-specific, global, enriched, and relationship intelligence fields.
    Cached for performance.

    Args:
        filter: Filter on the field name: ``name="Location"`` (exact) or ``name=~Loc``
            (substring); both case-sensitive. Combine clauses with ``|``. Only ``name``
            is filterable; anything else is rejected with 400.
        includes: Extra properties per field: ``"filterability"``, ``"sortability"``
            (read them via ``is_filterable`` / ``is_sortable``).
    """
    params, cache_key = build_fields_query(
        f"list_{list_id}_fields:",
        filter=filter,
        includes=includes,
    )

    data = self._client.get_all_pages(
        f"/lists/{list_id}/fields",
        params=params or None,
        cache_key=cache_key,
        cache_ttl=300,
    )

    return [_safe_model_validate(FieldMetadata, f) for f in data.get("data", [])]

get_first() -> AffinityList | None

Get the first list, or None if no lists exist.

Source code in affinity/services/lists.py
307
308
309
310
def get_first(self) -> AffinityList | None:
    """Get the first list, or None if no lists exist."""
    page = self.list(limit=1)
    return page.data[0] if page.data else None

get_saved_view(list_id: ListId, view_id: SavedViewId) -> SavedView

Get a single saved view.

Source code in affinity/services/lists.py
708
709
710
711
def get_saved_view(self, list_id: ListId, view_id: SavedViewId) -> SavedView:
    """Get a single saved view."""
    data = self._client.get(f"/lists/{list_id}/saved-views/{view_id}")
    return _safe_model_validate(SavedView, data)

get_saved_views(list_id: ListId, *, limit: int | None = None, cursor: str | None = None) -> PaginatedResponse[SavedView]

Get saved views for a list.

Parameters:

Name Type Description Default
list_id ListId

List id for the initial request.

required
limit int | None

Maximum results per page.

None
cursor str | None

Cursor to resume pagination (opaque; obtained from prior responses).

None
Source code in affinity/services/lists.py
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def get_saved_views(
    self,
    list_id: ListId,
    *,
    limit: int | None = None,
    cursor: str | None = None,
) -> PaginatedResponse[SavedView]:
    """
    Get saved views for a list.

    Args:
        list_id: List id for the initial request.
        limit: Maximum results per page.
        cursor: Cursor to resume pagination (opaque; obtained from prior responses).
    """
    if cursor is not None:
        if limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                "context. Start a new pagination sequence without a cursor to change "
                "parameters."
            )
        cursor_list_id = _saved_views_list_id_from_cursor(cursor)
        if cursor_list_id is not None and int(list_id) != cursor_list_id:
            raise ValueError(
                f"Cursor does not match list_id: cursor is for list {cursor_list_id}, "
                f"requested list_id is {int(list_id)}"
            )
        data = self._client.get_url(cursor)
    else:
        if limit is not None and limit <= 0:
            raise ValueError("'limit' must be > 0")
        params: dict[str, Any] = {}
        if limit is not None:
            params["limit"] = limit
        data = self._client.get(f"/lists/{list_id}/saved-views", params=params or None)

    return PaginatedResponse[SavedView](
        data=[_safe_model_validate(SavedView, v) for v in data.get("data", [])],
        pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
    )

get_size(list_id: ListId, *, force: bool = False) -> int

Get accurate list size. Uses V1 API, cached for 5 minutes.

Adding or removing a row through this client (entries(list_id).add_* / delete, opportunities.create) drops the cached size; changes made elsewhere show after the cache expires, or with force=True.

Parameters:

Name Type Description Default
list_id ListId

The list ID.

required
force bool

If True, bypass cache and fetch fresh value from API.

False

Note: The V2 API's listSize field is unreliable (often returns 0 for non-empty lists). This method uses the V1 API which returns accurate values.

Source code in affinity/services/lists.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
def get_size(self, list_id: ListId, *, force: bool = False) -> int:
    """
    Get accurate list size. Uses V1 API, cached for 5 minutes.

    Adding or removing a row through this client (``entries(list_id).add_*`` / ``delete``,
    ``opportunities.create``) drops the cached size; changes made elsewhere show after the
    cache expires, or with ``force=True``.

    Args:
        list_id: The list ID.
        force: If True, bypass cache and fetch fresh value from API.

    Note: The V2 API's listSize field is unreliable (often returns 0 for
    non-empty lists). This method uses the V1 API which returns accurate values.
    """
    if not force and list_id in self._size_cache:
        cached_at, size = self._size_cache[list_id]
        if time.monotonic() - cached_at < _SIZE_CACHE_TTL:
            return size

    # V2 has no list size: read it from V1
    data = self._client.get(f"/lists/{int(list_id)}", v1=True)
    size = _safe_model_validate(AffinityList, data)._list_size_hint
    self._size_cache[list_id] = (time.monotonic(), size)
    return size

iter() -> Iterator[AffinityList]

Auto-paginate all lists.

Alias for all() (FR-006 public contract).

Source code in affinity/services/lists.py
359
360
361
362
363
364
365
def iter(self) -> Iterator[AffinityList]:
    """
    Auto-paginate all lists.

    Alias for `all()` (FR-006 public contract).
    """
    return self.all()

list(*, limit: int | None = None, cursor: str | None = None, term: str | None = None) -> PaginatedResponse[AffinityList]

Get all lists accessible to you.

Parameters:

Name Type Description Default
limit int | None

Maximum results per page.

None
cursor str | None

Cursor to resume pagination (opaque; obtained from prior responses).

None
term str | None

Only lists whose name contains this text (case-insensitive substring, matched by Affinity). Kept across pages by the cursor.

None

Returns:

Type Description
PaginatedResponse[AffinityList]

Paginated list of lists (without field metadata)

Source code in affinity/services/lists.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def list(
    self,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    term: str | None = None,
) -> PaginatedResponse[AffinityList]:
    """
    Get all lists accessible to you.

    Args:
        limit: Maximum results per page.
        cursor: Cursor to resume pagination (opaque; obtained from prior responses).
        term: Only lists whose name contains this text (case-insensitive substring,
            matched by Affinity). Kept across pages by the cursor.

    Returns:
        Paginated list of lists (without field metadata)
    """
    term = term or None
    if cursor is not None:
        if limit is not None or term is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                "context. Start a new pagination sequence without a cursor to change "
                "parameters."
            )
        data = self._client.get_url(cursor)
    else:
        if limit is not None and limit <= 0:
            raise ValueError("'limit' must be > 0")
        params: dict[str, Any] = {}
        if limit is not None:
            params["limit"] = limit
        if term is not None:
            params["term"] = term
        data = self._client.get("/lists", params=params or None)

    return PaginatedResponse[AffinityList](
        data=[
            _safe_model_validate(AffinityList, list_item) for list_item in data.get("data", [])
        ],
        pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
    )

pages(*, limit: int | None = None, cursor: str | None = None, term: str | None = None) -> Iterator[PaginatedResponse[AffinityList]]

Iterate list pages (not items), yielding PaginatedResponse[AffinityList].

This is useful for ETL scripts that want checkpoint/resume via page.next_cursor. term keeps only lists whose name contains it (case-insensitive, matched by Affinity).

Source code in affinity/services/lists.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def pages(
    self,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    term: str | None = None,
) -> Iterator[PaginatedResponse[AffinityList]]:
    """
    Iterate list pages (not items), yielding `PaginatedResponse[AffinityList]`.

    This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
    ``term`` keeps only lists whose name contains it (case-insensitive, matched by Affinity).
    """
    term = term or None
    if cursor is not None and (limit is not None or term is not None):
        raise ValueError(
            "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
            "Start a new pagination sequence without a cursor to change parameters."
        )
    requested_cursor = cursor
    guard = _CursorGuard(cursor)
    page = self.list(limit=limit, term=term) if cursor is None else self.list(cursor=cursor)
    while True:
        yield page
        # Stop at the last page, or when paging can't progress (see _CursorGuard).
        if guard.last_page(requested_cursor, page):
            return
        requested_cursor = page.next_cursor
        page = self.list(cursor=requested_cursor)

resolve(*, name: str, list_type: ListType | None = None) -> AffinityList | None

Find a single list by name (case-insensitive exact match; optionally filtered by type).

Notes: - Affinity narrows the lists by name first (term); the exact comparison is made here. If that finds nothing, every list is checked (see resolve_all()). - Matches are cached in-memory on this service instance (a miss is not). If you call this frequently, reuse the client, or persist the resolved ListId in your own configuration.

If multiple matches exist, returns the first match in server-provided order.

Source code in affinity/services/lists.py
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
def resolve(
    self,
    *,
    name: str,
    list_type: ListType | None = None,
) -> AffinityList | None:
    """
    Find a single list by name (case-insensitive exact match; optionally filtered by type).

    Notes:
    - Affinity narrows the lists by name first (``term``); the exact comparison is made
      here. If that finds nothing, every list is checked (see `resolve_all()`).
    - Matches are cached in-memory on this service instance (a miss is not). If you call
      this frequently, reuse the client, or persist the resolved `ListId` in your own
      configuration.

    If multiple matches exist, returns the first match in server-provided order.
    """
    key = (name.lower(), list_type)
    if key in self._resolve_cache:
        return self._resolve_cache[key]

    matches = self.resolve_all(name=name, list_type=list_type)
    found = matches[0] if matches else None
    if found is not None:  # a miss isn't cached: the list may be created later
        self._resolve_cache[key] = found
    return found

resolve_all(*, name: str, list_type: ListType | None = None) -> builtins.list[AffinityList]

Find all lists matching a name (case-insensitive exact match; optionally by type).

Notes: - For an ASCII name, Affinity first narrows the lists by name (term, a case-insensitive substring match) and the exact comparison is made here. If that yields no match, every list is checked, so a difference in how Affinity folds case or whitespace can't hide a list. Non-ASCII names always check every list. - Unlike resolve(), this does not cache results.

Source code in affinity/services/lists.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def resolve_all(
    self,
    *,
    name: str,
    list_type: ListType | None = None,
) -> builtins.list[AffinityList]:
    """
    Find all lists matching a name (case-insensitive exact match; optionally by type).

    Notes:
    - For an ASCII name, Affinity first narrows the lists by name (``term``, a
      case-insensitive substring match) and the exact comparison is made here. If that
      yields no match, every list is checked, so a difference in how Affinity folds case
      or whitespace can't hide a list. Non-ASCII names always check every list.
    - Unlike `resolve()`, this does not cache results.
    """
    term = name.strip()
    if term and term.isascii():
        candidates = (item for page in self.pages(term=term) for item in page.data)
        matches = _exact_name_matches(candidates, name, list_type)
        if matches:
            return matches
    return _exact_name_matches(self.all(), name, list_type)

saved_views_all(list_id: ListId) -> Iterator[SavedView]

Iterate all saved views for a list.

Source code in affinity/services/lists.py
703
704
705
706
def saved_views_all(self, list_id: ListId) -> Iterator[SavedView]:
    """Iterate all saved views for a list."""
    for page in self.saved_views_pages(list_id):
        yield from page.data

saved_views_pages(list_id: ListId, *, limit: int | None = None, cursor: str | None = None) -> Iterator[PaginatedResponse[SavedView]]

Iterate saved view pages, yielding PaginatedResponse[SavedView].

Source code in affinity/services/lists.py
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
def saved_views_pages(
    self,
    list_id: ListId,
    *,
    limit: int | None = None,
    cursor: str | None = None,
) -> Iterator[PaginatedResponse[SavedView]]:
    """Iterate saved view pages, yielding `PaginatedResponse[SavedView]`."""
    if cursor is not None and limit is not None:
        raise ValueError(
            "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
            "Start a new pagination sequence without a cursor to change parameters."
        )
    requested_cursor = cursor
    guard = _CursorGuard(cursor)
    page = (
        self.get_saved_views(list_id, limit=limit)
        if cursor is None
        else self.get_saved_views(list_id, cursor=cursor)
    )
    while True:
        yield page
        # Stop at the last page, or when paging can't progress (see _CursorGuard).
        if guard.last_page(requested_cursor, page):
            return
        requested_cursor = page.next_cursor
        page = self.get_saved_views(list_id, cursor=requested_cursor)

update_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, option_id: int, *, text: str | None = None, rank: int | None = None, color: DropdownOptionColorName | None = None, status_category: DropdownStatusCategory | None = None, win_rate: int | None = None) -> DropdownOption

Change an option of a list field (only what you pass).

A dropdown option can change its text; ranked-dropdown also rank and color; status-dropdown also status category and win rate. Renaming changes the value shown on every entry that has this option. Returns the option as read back afterwards.

Source code in affinity/services/lists.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
def update_field_dropdown_option(
    self,
    list_id: ListId,
    field_id: AnyFieldId,
    option_id: int,
    *,
    text: str | None = None,
    rank: int | None = None,
    color: DropdownOptionColorName | None = None,
    status_category: DropdownStatusCategory | None = None,
    win_rate: int | None = None,
) -> DropdownOption:
    """Change an option of a list field (only what you pass).

    A ``dropdown`` option can change its text; ``ranked-dropdown`` also rank and color;
    ``status-dropdown`` also status category and win rate. Renaming changes the value shown
    on every entry that has this option. Returns the option as read back afterwards.
    """
    body = update_body(
        text=text,
        rank=rank,
        color=color,
        status_category=status_category,
        win_rate=win_rate,
    )
    self._client.post(
        option_path(list_id, field_id, option_id),
        json=body,
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
    )
    # The API answers 204 with no body: read the option back
    return DropdownOption.model_validate(
        self._client.get(
            option_path(list_id, field_id, option_id),
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
    )

Async list operations (TR-009).

Source code in affinity/services/lists.py
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
class AsyncListService:
    """Async list operations (TR-009)."""

    def __init__(self, client: AsyncHTTPClient):
        self._client = client
        self._resolve_cache: dict[tuple[str, ListType | None], AffinityList | None] = {}
        # (timestamp, size), shared by every list service on this client
        self._size_cache: dict[int, tuple[float, int]] = _size_cache_for(client)
        # Fields, dropdown options and refused searches for filtered reads, per list
        self._search_cache = SearchCache()

    def entries(self, list_id: ListId) -> AsyncListEntryService:
        """
        Get an AsyncListEntryService for a specific list.

        This is the explicit path for retrieving "full row" data via list entries.
        """
        return AsyncListEntryService(self._client, list_id, self._search_cache)

    async def list(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
        term: str | None = None,
    ) -> PaginatedResponse[AffinityList]:
        """
        Get all lists accessible to you.

        Args:
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).
            term: Only lists whose name contains this text (case-insensitive substring,
                matched by Affinity). Kept across pages by the cursor.

        Returns:
            Paginated list of lists (without field metadata)
        """
        term = term or None
        if cursor is not None:
            if limit is not None or term is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            data = await self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            if term is not None:
                params["term"] = term
            data = await self._client.get("/lists", params=params or None)
        return PaginatedResponse[AffinityList](
            data=[
                _safe_model_validate(AffinityList, list_item) for list_item in data.get("data", [])
            ],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    async def get_first(self) -> AffinityList | None:
        """Get the first list, or None if no lists exist."""
        page = await self.list(limit=1)
        return page.data[0] if page.data else None

    async def pages(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
        term: str | None = None,
    ) -> AsyncIterator[PaginatedResponse[AffinityList]]:
        """
        Iterate list pages (not items), yielding `PaginatedResponse[AffinityList]`.

        This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
        ``term`` keeps only lists whose name contains it (case-insensitive, matched by Affinity).
        """
        term = term or None
        if cursor is not None and (limit is not None or term is not None):
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        guard = _CursorGuard(cursor)
        page = (
            await self.list(limit=limit, term=term)
            if cursor is None
            else await self.list(cursor=cursor)
        )
        while True:
            yield page
            # Stop at the last page, or when paging can't progress (see _CursorGuard).
            if guard.last_page(requested_cursor, page):
                return
            requested_cursor = page.next_cursor
            page = await self.list(cursor=requested_cursor)

    def all(self) -> AsyncIterator[AffinityList]:
        """Iterate through all accessible lists."""

        async def fetch_page(next_url: str | None) -> PaginatedResponse[AffinityList]:
            if next_url:
                data = await self._client.get_url(next_url)
                return PaginatedResponse[AffinityList](
                    data=[
                        _safe_model_validate(AffinityList, list_item)
                        for list_item in data.get("data", [])
                    ],
                    pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
                )
            return await self.list()

        return AsyncPageIterator(fetch_page)

    def iter(self) -> AsyncIterator[AffinityList]:
        """
        Auto-paginate all lists.

        Alias for `all()` (FR-006 public contract).
        """
        return self.all()

    # =========================================================================
    # Saved View Operations
    # =========================================================================

    async def get_saved_views(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[SavedView]:
        """
        Get saved views for a list.

        Args:
            list_id: List id for the initial request.
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).
        """
        if cursor is not None:
            if limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            cursor_list_id = _saved_views_list_id_from_cursor(cursor)
            if cursor_list_id is not None and int(list_id) != cursor_list_id:
                raise ValueError(
                    f"Cursor does not match list_id: cursor is for list {cursor_list_id}, "
                    f"requested list_id is {int(list_id)}"
                )
            data = await self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            data = await self._client.get(f"/lists/{list_id}/saved-views", params=params or None)

        return PaginatedResponse[SavedView](
            data=[_safe_model_validate(SavedView, v) for v in data.get("data", [])],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    async def saved_views_pages(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> AsyncIterator[PaginatedResponse[SavedView]]:
        """Iterate saved view pages, yielding `PaginatedResponse[SavedView]`."""
        if cursor is not None and limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        guard = _CursorGuard(cursor)
        page = (
            await self.get_saved_views(list_id, limit=limit)
            if cursor is None
            else await self.get_saved_views(list_id, cursor=cursor)
        )
        while True:
            yield page
            # Stop at the last page, or when paging can't progress (see _CursorGuard).
            if guard.last_page(requested_cursor, page):
                return
            requested_cursor = page.next_cursor
            page = await self.get_saved_views(list_id, cursor=requested_cursor)

    async def saved_views_all(self, list_id: ListId) -> AsyncIterator[SavedView]:
        """Iterate all saved views for a list."""
        async for page in self.saved_views_pages(list_id):
            for item in page.data:
                yield item

    async def get_saved_view(self, list_id: ListId, view_id: SavedViewId) -> SavedView:
        """Get a single saved view."""
        data = await self._client.get(f"/lists/{list_id}/saved-views/{view_id}")
        return _safe_model_validate(SavedView, data)

    async def get(self, list_id: ListId) -> AffinityList:
        """
        Get a single list by ID (V2).

        ``fields`` is not filled: use :meth:`get_fields`. For the number of entries use
        :meth:`get_size`.
        """
        data = await self._client.get(f"/lists/{int(list_id)}")
        return _safe_model_validate(AffinityList, data)

    async def get_size(self, list_id: ListId, *, force: bool = False) -> int:
        """
        Get accurate list size. Uses V1 API, cached for 5 minutes.

        Adding or removing a row through this client (``entries(list_id).add_*`` / ``delete``,
        ``opportunities.create``) drops the cached size; changes made elsewhere show after the
        cache expires, or with ``force=True``.

        Args:
            list_id: The list ID.
            force: If True, bypass cache and fetch fresh value from API.

        Note: The V2 API's listSize field is unreliable (often returns 0 for
        non-empty lists). This method uses the V1 API which returns accurate values.
        """
        if not force and list_id in self._size_cache:
            cached_at, size = self._size_cache[list_id]
            if time.monotonic() - cached_at < _SIZE_CACHE_TTL:
                return size

        # V2 has no list size: read it from V1
        data = await self._client.get(f"/lists/{int(list_id)}", v1=True)
        size = _safe_model_validate(AffinityList, data)._list_size_hint
        self._size_cache[list_id] = (time.monotonic(), size)
        return size

    async def resolve(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> AffinityList | None:
        """
        Find a single list by name (case-insensitive exact match; optionally filtered by type).

        Notes:
        - Affinity narrows the lists by name first (``term``); the exact comparison is made
          here. If that finds nothing, every list is checked (see `resolve_all()`).
        - Matches are cached in-memory on this service instance (a miss is not). If you call
          this frequently, reuse the client, or persist the resolved `ListId` in your own
          configuration.

        If multiple matches exist, returns the first match in server-provided order.
        """
        key = (name.lower(), list_type)
        if key in self._resolve_cache:
            return self._resolve_cache[key]

        matches = await self.resolve_all(name=name, list_type=list_type)
        found = matches[0] if matches else None
        if found is not None:  # a miss isn't cached: the list may be created later
            self._resolve_cache[key] = found
        return found

    async def resolve_all(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> builtins.list[AffinityList]:
        """
        Find all lists matching a name (case-insensitive exact match; optionally by type).

        Notes:
        - For an ASCII name, Affinity first narrows the lists by name (``term``, a
          case-insensitive substring match) and the exact comparison is made here. If that
          yields no match, every list is checked, so a difference in how Affinity folds case
          or whitespace can't hide a list. Non-ASCII names always check every list.
        - Unlike `resolve()`, this does not cache results.
        """
        term = name.strip()
        if term and term.isascii():
            candidates = [item async for page in self.pages(term=term) for item in page.data]
            matches = _exact_name_matches(candidates, name, list_type)
            if matches:
                return matches
        return _exact_name_matches([item async for item in self.all()], name, list_type)

    # =========================================================================
    # Write Operations (V1 API)
    # =========================================================================

    async def create(self, data: ListCreate) -> AffinityList:
        """
        Create a list (V2); the caller becomes its creator and owner.

        A public list needs the "Share accessible Lists globally" permission. V2 can't set
        ``owner_id`` or ``additional_permissions``: with either, the list is created through V1.
        """
        if _uses_v1_create(data):
            result = await self._client.post("/lists", json=_v1_list_create_payload(data), v1=True)
        else:
            result = await self._client.post("/lists", json=_list_create_body(data))

        if self._client.cache:
            self._client.cache.invalidate_prefix("list")
        self._resolve_cache.clear()

        return _safe_model_validate(AffinityList, result)

    # =========================================================================
    # Field Operations
    # =========================================================================

    async def get_fields(
        self,
        list_id: ListId,
        *,
        filter: str | None = None,
        includes: Sequence[str] | None = None,
    ) -> builtins.list[FieldMetadata]:
        """
        Get fields (columns) for a list.

        Includes list-specific, global, enriched, and relationship intelligence fields.
        Cached for performance.

        Args:
            filter: Filter on the field name: ``name="Location"`` (exact) or ``name=~Loc``
                (substring); both case-sensitive. Combine clauses with ``|``. Only ``name``
                is filterable; anything else is rejected with 400.
            includes: Extra properties per field: ``"filterability"``, ``"sortability"``
                (read them via ``is_filterable`` / ``is_sortable``).
        """
        params, cache_key = build_fields_query(
            f"list_{list_id}_fields:",
            filter=filter,
            includes=includes,
        )

        data = await self._client.get_all_pages(
            f"/lists/{list_id}/fields",
            params=params or None,
            cache_key=cache_key,
            cache_ttl=300,
        )

        return [_safe_model_validate(FieldMetadata, f) for f in data.get("data", [])]

    # =========================================================================
    # Dropdown options of list fields
    # =========================================================================

    async def get_field_dropdown_options(
        self, list_id: ListId, field_id: AnyFieldId, *, with_status_types: bool = False
    ) -> builtins.list[DropdownOption]:
        """All options of a list field's dropdown, ranked-dropdown or status field (every page).

        Older API versions report status options as ``ranked-dropdown`` without
        ``status_category``. ``with_status_types=True`` reads with API version
        {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (as the option writes do) to get the real
        ``status-dropdown`` type; a client pinned to an older version then raises
        ``ApiVersionTooOldError``.
        """
        data = await self._client.get_all_pages(
            options_path(field_id, list_id=list_id, entity=None),
            params={"limit": 100},
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION if with_status_types else None,
        )
        return parse_options(data)

    async def get_field_dropdown_option(
        self, list_id: ListId, field_id: AnyFieldId, option_id: int
    ) -> DropdownOption:
        """One option of a list field."""
        return DropdownOption.model_validate(
            await self._client.get(option_path(list_id, field_id, option_id))
        )

    async def create_field_dropdown_option(
        self,
        list_id: ListId,
        field_id: AnyFieldId,
        *,
        option_type: DropdownOptionType,
        text: str,
        rank: int | None = None,
        color: DropdownOptionColorName | None = None,
        status_category: DropdownStatusCategory | None = None,
        win_rate: int | None = None,
    ) -> DropdownOption:
        """Add an option to a list field.

        ``option_type`` must match the field: ``"dropdown"`` (text only), ``"ranked-dropdown"``
        (``rank`` and ``color`` required) or ``"status-dropdown"`` (``rank``, ``color`` and
        ``status_category`` required; ``win_rate`` 0-100 only for ``"open"``). Needs Affinity API
        version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (sent for this call).
        """
        body = create_body(
            option_type=option_type,
            text=text,
            rank=rank,
            color=color,
            status_category=status_category,
            win_rate=win_rate,
        )
        data = await self._client.post(
            options_path(field_id, list_id=list_id, entity=None),
            json=body,
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
        return DropdownOption.model_validate(data)

    async def update_field_dropdown_option(
        self,
        list_id: ListId,
        field_id: AnyFieldId,
        option_id: int,
        *,
        text: str | None = None,
        rank: int | None = None,
        color: DropdownOptionColorName | None = None,
        status_category: DropdownStatusCategory | None = None,
        win_rate: int | None = None,
    ) -> DropdownOption:
        """Change an option of a list field (only what you pass).

        A ``dropdown`` option can change its text; ``ranked-dropdown`` also rank and color;
        ``status-dropdown`` also status category and win rate. Renaming changes the value shown
        on every entry that has this option. Returns the option as read back afterwards.
        """
        body = update_body(
            text=text,
            rank=rank,
            color=color,
            status_category=status_category,
            win_rate=win_rate,
        )
        await self._client.post(
            option_path(list_id, field_id, option_id),
            json=body,
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
        # The API answers 204 with no body: read the option back
        return DropdownOption.model_validate(
            await self._client.get(
                option_path(list_id, field_id, option_id),
                min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
            )
        )

    async def delete_field_dropdown_option(
        self, list_id: ListId, field_id: AnyFieldId, option_id: int
    ) -> bool:
        """Delete an option of a list field. **Cannot be undone.**

        Affinity also clears this field on every list entry currently set to the option; those
        values cannot be recovered.
        """
        await self._client.delete(
            option_path(list_id, field_id, option_id),
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
        return True

all() -> AsyncIterator[AffinityList]

Iterate through all accessible lists.

Source code in affinity/services/lists.py
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
def all(self) -> AsyncIterator[AffinityList]:
    """Iterate through all accessible lists."""

    async def fetch_page(next_url: str | None) -> PaginatedResponse[AffinityList]:
        if next_url:
            data = await self._client.get_url(next_url)
            return PaginatedResponse[AffinityList](
                data=[
                    _safe_model_validate(AffinityList, list_item)
                    for list_item in data.get("data", [])
                ],
                pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
            )
        return await self.list()

    return AsyncPageIterator(fetch_page)

create(data: ListCreate) -> AffinityList async

Create a list (V2); the caller becomes its creator and owner.

A public list needs the "Share accessible Lists globally" permission. V2 can't set owner_id or additional_permissions: with either, the list is created through V1.

Source code in affinity/services/lists.py
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
async def create(self, data: ListCreate) -> AffinityList:
    """
    Create a list (V2); the caller becomes its creator and owner.

    A public list needs the "Share accessible Lists globally" permission. V2 can't set
    ``owner_id`` or ``additional_permissions``: with either, the list is created through V1.
    """
    if _uses_v1_create(data):
        result = await self._client.post("/lists", json=_v1_list_create_payload(data), v1=True)
    else:
        result = await self._client.post("/lists", json=_list_create_body(data))

    if self._client.cache:
        self._client.cache.invalidate_prefix("list")
    self._resolve_cache.clear()

    return _safe_model_validate(AffinityList, result)

create_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, *, option_type: DropdownOptionType, text: str, rank: int | None = None, color: DropdownOptionColorName | None = None, status_category: DropdownStatusCategory | None = None, win_rate: int | None = None) -> DropdownOption async

Add an option to a list field.

option_type must match the field: "dropdown" (text only), "ranked-dropdown" (rank and color required) or "status-dropdown" (rank, color and status_category required; win_rate 0-100 only for "open"). Needs Affinity API version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (sent for this call).

Source code in affinity/services/lists.py
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
async def create_field_dropdown_option(
    self,
    list_id: ListId,
    field_id: AnyFieldId,
    *,
    option_type: DropdownOptionType,
    text: str,
    rank: int | None = None,
    color: DropdownOptionColorName | None = None,
    status_category: DropdownStatusCategory | None = None,
    win_rate: int | None = None,
) -> DropdownOption:
    """Add an option to a list field.

    ``option_type`` must match the field: ``"dropdown"`` (text only), ``"ranked-dropdown"``
    (``rank`` and ``color`` required) or ``"status-dropdown"`` (``rank``, ``color`` and
    ``status_category`` required; ``win_rate`` 0-100 only for ``"open"``). Needs Affinity API
    version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (sent for this call).
    """
    body = create_body(
        option_type=option_type,
        text=text,
        rank=rank,
        color=color,
        status_category=status_category,
        win_rate=win_rate,
    )
    data = await self._client.post(
        options_path(field_id, list_id=list_id, entity=None),
        json=body,
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
    )
    return DropdownOption.model_validate(data)

delete_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, option_id: int) -> bool async

Delete an option of a list field. Cannot be undone.

Affinity also clears this field on every list entry currently set to the option; those values cannot be recovered.

Source code in affinity/services/lists.py
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
async def delete_field_dropdown_option(
    self, list_id: ListId, field_id: AnyFieldId, option_id: int
) -> bool:
    """Delete an option of a list field. **Cannot be undone.**

    Affinity also clears this field on every list entry currently set to the option; those
    values cannot be recovered.
    """
    await self._client.delete(
        option_path(list_id, field_id, option_id),
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
    )
    return True

entries(list_id: ListId) -> AsyncListEntryService

Get an AsyncListEntryService for a specific list.

This is the explicit path for retrieving "full row" data via list entries.

Source code in affinity/services/lists.py
1711
1712
1713
1714
1715
1716
1717
def entries(self, list_id: ListId) -> AsyncListEntryService:
    """
    Get an AsyncListEntryService for a specific list.

    This is the explicit path for retrieving "full row" data via list entries.
    """
    return AsyncListEntryService(self._client, list_id, self._search_cache)

get(list_id: ListId) -> AffinityList async

Get a single list by ID (V2).

fields is not filled: use :meth:get_fields. For the number of entries use :meth:get_size.

Source code in affinity/services/lists.py
1912
1913
1914
1915
1916
1917
1918
1919
1920
async def get(self, list_id: ListId) -> AffinityList:
    """
    Get a single list by ID (V2).

    ``fields`` is not filled: use :meth:`get_fields`. For the number of entries use
    :meth:`get_size`.
    """
    data = await self._client.get(f"/lists/{int(list_id)}")
    return _safe_model_validate(AffinityList, data)

get_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, option_id: int) -> DropdownOption async

One option of a list field.

Source code in affinity/services/lists.py
2083
2084
2085
2086
2087
2088
2089
async def get_field_dropdown_option(
    self, list_id: ListId, field_id: AnyFieldId, option_id: int
) -> DropdownOption:
    """One option of a list field."""
    return DropdownOption.model_validate(
        await self._client.get(option_path(list_id, field_id, option_id))
    )

get_field_dropdown_options(list_id: ListId, field_id: AnyFieldId, *, with_status_types: bool = False) -> builtins.list[DropdownOption] async

All options of a list field's dropdown, ranked-dropdown or status field (every page).

Older API versions report status options as ranked-dropdown without status_category. with_status_types=True reads with API version {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (as the option writes do) to get the real status-dropdown type; a client pinned to an older version then raises ApiVersionTooOldError.

Source code in affinity/services/lists.py
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
async def get_field_dropdown_options(
    self, list_id: ListId, field_id: AnyFieldId, *, with_status_types: bool = False
) -> builtins.list[DropdownOption]:
    """All options of a list field's dropdown, ranked-dropdown or status field (every page).

    Older API versions report status options as ``ranked-dropdown`` without
    ``status_category``. ``with_status_types=True`` reads with API version
    {DROPDOWN_OPTION_WRITES_MIN_API_VERSION} (as the option writes do) to get the real
    ``status-dropdown`` type; a client pinned to an older version then raises
    ``ApiVersionTooOldError``.
    """
    data = await self._client.get_all_pages(
        options_path(field_id, list_id=list_id, entity=None),
        params={"limit": 100},
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION if with_status_types else None,
    )
    return parse_options(data)

get_fields(list_id: ListId, *, filter: str | None = None, includes: Sequence[str] | None = None) -> builtins.list[FieldMetadata] async

Get fields (columns) for a list.

Includes list-specific, global, enriched, and relationship intelligence fields. Cached for performance.

Parameters:

Name Type Description Default
filter str | None

Filter on the field name: name="Location" (exact) or name=~Loc (substring); both case-sensitive. Combine clauses with |. Only name is filterable; anything else is rejected with 400.

None
includes Sequence[str] | None

Extra properties per field: "filterability", "sortability" (read them via is_filterable / is_sortable).

None
Source code in affinity/services/lists.py
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
async def get_fields(
    self,
    list_id: ListId,
    *,
    filter: str | None = None,
    includes: Sequence[str] | None = None,
) -> builtins.list[FieldMetadata]:
    """
    Get fields (columns) for a list.

    Includes list-specific, global, enriched, and relationship intelligence fields.
    Cached for performance.

    Args:
        filter: Filter on the field name: ``name="Location"`` (exact) or ``name=~Loc``
            (substring); both case-sensitive. Combine clauses with ``|``. Only ``name``
            is filterable; anything else is rejected with 400.
        includes: Extra properties per field: ``"filterability"``, ``"sortability"``
            (read them via ``is_filterable`` / ``is_sortable``).
    """
    params, cache_key = build_fields_query(
        f"list_{list_id}_fields:",
        filter=filter,
        includes=includes,
    )

    data = await self._client.get_all_pages(
        f"/lists/{list_id}/fields",
        params=params or None,
        cache_key=cache_key,
        cache_ttl=300,
    )

    return [_safe_model_validate(FieldMetadata, f) for f in data.get("data", [])]

get_first() -> AffinityList | None async

Get the first list, or None if no lists exist.

Source code in affinity/services/lists.py
1763
1764
1765
1766
async def get_first(self) -> AffinityList | None:
    """Get the first list, or None if no lists exist."""
    page = await self.list(limit=1)
    return page.data[0] if page.data else None

get_saved_view(list_id: ListId, view_id: SavedViewId) -> SavedView async

Get a single saved view.

Source code in affinity/services/lists.py
1907
1908
1909
1910
async def get_saved_view(self, list_id: ListId, view_id: SavedViewId) -> SavedView:
    """Get a single saved view."""
    data = await self._client.get(f"/lists/{list_id}/saved-views/{view_id}")
    return _safe_model_validate(SavedView, data)

get_saved_views(list_id: ListId, *, limit: int | None = None, cursor: str | None = None) -> PaginatedResponse[SavedView] async

Get saved views for a list.

Parameters:

Name Type Description Default
list_id ListId

List id for the initial request.

required
limit int | None

Maximum results per page.

None
cursor str | None

Cursor to resume pagination (opaque; obtained from prior responses).

None
Source code in affinity/services/lists.py
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
async def get_saved_views(
    self,
    list_id: ListId,
    *,
    limit: int | None = None,
    cursor: str | None = None,
) -> PaginatedResponse[SavedView]:
    """
    Get saved views for a list.

    Args:
        list_id: List id for the initial request.
        limit: Maximum results per page.
        cursor: Cursor to resume pagination (opaque; obtained from prior responses).
    """
    if cursor is not None:
        if limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                "context. Start a new pagination sequence without a cursor to change "
                "parameters."
            )
        cursor_list_id = _saved_views_list_id_from_cursor(cursor)
        if cursor_list_id is not None and int(list_id) != cursor_list_id:
            raise ValueError(
                f"Cursor does not match list_id: cursor is for list {cursor_list_id}, "
                f"requested list_id is {int(list_id)}"
            )
        data = await self._client.get_url(cursor)
    else:
        if limit is not None and limit <= 0:
            raise ValueError("'limit' must be > 0")
        params: dict[str, Any] = {}
        if limit is not None:
            params["limit"] = limit
        data = await self._client.get(f"/lists/{list_id}/saved-views", params=params or None)

    return PaginatedResponse[SavedView](
        data=[_safe_model_validate(SavedView, v) for v in data.get("data", [])],
        pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
    )

get_size(list_id: ListId, *, force: bool = False) -> int async

Get accurate list size. Uses V1 API, cached for 5 minutes.

Adding or removing a row through this client (entries(list_id).add_* / delete, opportunities.create) drops the cached size; changes made elsewhere show after the cache expires, or with force=True.

Parameters:

Name Type Description Default
list_id ListId

The list ID.

required
force bool

If True, bypass cache and fetch fresh value from API.

False

Note: The V2 API's listSize field is unreliable (often returns 0 for non-empty lists). This method uses the V1 API which returns accurate values.

Source code in affinity/services/lists.py
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
async def get_size(self, list_id: ListId, *, force: bool = False) -> int:
    """
    Get accurate list size. Uses V1 API, cached for 5 minutes.

    Adding or removing a row through this client (``entries(list_id).add_*`` / ``delete``,
    ``opportunities.create``) drops the cached size; changes made elsewhere show after the
    cache expires, or with ``force=True``.

    Args:
        list_id: The list ID.
        force: If True, bypass cache and fetch fresh value from API.

    Note: The V2 API's listSize field is unreliable (often returns 0 for
    non-empty lists). This method uses the V1 API which returns accurate values.
    """
    if not force and list_id in self._size_cache:
        cached_at, size = self._size_cache[list_id]
        if time.monotonic() - cached_at < _SIZE_CACHE_TTL:
            return size

    # V2 has no list size: read it from V1
    data = await self._client.get(f"/lists/{int(list_id)}", v1=True)
    size = _safe_model_validate(AffinityList, data)._list_size_hint
    self._size_cache[list_id] = (time.monotonic(), size)
    return size

iter() -> AsyncIterator[AffinityList]

Auto-paginate all lists.

Alias for all() (FR-006 public contract).

Source code in affinity/services/lists.py
1819
1820
1821
1822
1823
1824
1825
def iter(self) -> AsyncIterator[AffinityList]:
    """
    Auto-paginate all lists.

    Alias for `all()` (FR-006 public contract).
    """
    return self.all()

list(*, limit: int | None = None, cursor: str | None = None, term: str | None = None) -> PaginatedResponse[AffinityList] async

Get all lists accessible to you.

Parameters:

Name Type Description Default
limit int | None

Maximum results per page.

None
cursor str | None

Cursor to resume pagination (opaque; obtained from prior responses).

None
term str | None

Only lists whose name contains this text (case-insensitive substring, matched by Affinity). Kept across pages by the cursor.

None

Returns:

Type Description
PaginatedResponse[AffinityList]

Paginated list of lists (without field metadata)

Source code in affinity/services/lists.py
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
async def list(
    self,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    term: str | None = None,
) -> PaginatedResponse[AffinityList]:
    """
    Get all lists accessible to you.

    Args:
        limit: Maximum results per page.
        cursor: Cursor to resume pagination (opaque; obtained from prior responses).
        term: Only lists whose name contains this text (case-insensitive substring,
            matched by Affinity). Kept across pages by the cursor.

    Returns:
        Paginated list of lists (without field metadata)
    """
    term = term or None
    if cursor is not None:
        if limit is not None or term is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                "context. Start a new pagination sequence without a cursor to change "
                "parameters."
            )
        data = await self._client.get_url(cursor)
    else:
        if limit is not None and limit <= 0:
            raise ValueError("'limit' must be > 0")
        params: dict[str, Any] = {}
        if limit is not None:
            params["limit"] = limit
        if term is not None:
            params["term"] = term
        data = await self._client.get("/lists", params=params or None)
    return PaginatedResponse[AffinityList](
        data=[
            _safe_model_validate(AffinityList, list_item) for list_item in data.get("data", [])
        ],
        pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
    )

pages(*, limit: int | None = None, cursor: str | None = None, term: str | None = None) -> AsyncIterator[PaginatedResponse[AffinityList]] async

Iterate list pages (not items), yielding PaginatedResponse[AffinityList].

This is useful for ETL scripts that want checkpoint/resume via page.next_cursor. term keeps only lists whose name contains it (case-insensitive, matched by Affinity).

Source code in affinity/services/lists.py
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
async def pages(
    self,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    term: str | None = None,
) -> AsyncIterator[PaginatedResponse[AffinityList]]:
    """
    Iterate list pages (not items), yielding `PaginatedResponse[AffinityList]`.

    This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
    ``term`` keeps only lists whose name contains it (case-insensitive, matched by Affinity).
    """
    term = term or None
    if cursor is not None and (limit is not None or term is not None):
        raise ValueError(
            "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
            "Start a new pagination sequence without a cursor to change parameters."
        )
    requested_cursor = cursor
    guard = _CursorGuard(cursor)
    page = (
        await self.list(limit=limit, term=term)
        if cursor is None
        else await self.list(cursor=cursor)
    )
    while True:
        yield page
        # Stop at the last page, or when paging can't progress (see _CursorGuard).
        if guard.last_page(requested_cursor, page):
            return
        requested_cursor = page.next_cursor
        page = await self.list(cursor=requested_cursor)

resolve(*, name: str, list_type: ListType | None = None) -> AffinityList | None async

Find a single list by name (case-insensitive exact match; optionally filtered by type).

Notes: - Affinity narrows the lists by name first (term); the exact comparison is made here. If that finds nothing, every list is checked (see resolve_all()). - Matches are cached in-memory on this service instance (a miss is not). If you call this frequently, reuse the client, or persist the resolved ListId in your own configuration.

If multiple matches exist, returns the first match in server-provided order.

Source code in affinity/services/lists.py
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
async def resolve(
    self,
    *,
    name: str,
    list_type: ListType | None = None,
) -> AffinityList | None:
    """
    Find a single list by name (case-insensitive exact match; optionally filtered by type).

    Notes:
    - Affinity narrows the lists by name first (``term``); the exact comparison is made
      here. If that finds nothing, every list is checked (see `resolve_all()`).
    - Matches are cached in-memory on this service instance (a miss is not). If you call
      this frequently, reuse the client, or persist the resolved `ListId` in your own
      configuration.

    If multiple matches exist, returns the first match in server-provided order.
    """
    key = (name.lower(), list_type)
    if key in self._resolve_cache:
        return self._resolve_cache[key]

    matches = await self.resolve_all(name=name, list_type=list_type)
    found = matches[0] if matches else None
    if found is not None:  # a miss isn't cached: the list may be created later
        self._resolve_cache[key] = found
    return found

resolve_all(*, name: str, list_type: ListType | None = None) -> builtins.list[AffinityList] async

Find all lists matching a name (case-insensitive exact match; optionally by type).

Notes: - For an ASCII name, Affinity first narrows the lists by name (term, a case-insensitive substring match) and the exact comparison is made here. If that yields no match, every list is checked, so a difference in how Affinity folds case or whitespace can't hide a list. Non-ASCII names always check every list. - Unlike resolve(), this does not cache results.

Source code in affinity/services/lists.py
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
async def resolve_all(
    self,
    *,
    name: str,
    list_type: ListType | None = None,
) -> builtins.list[AffinityList]:
    """
    Find all lists matching a name (case-insensitive exact match; optionally by type).

    Notes:
    - For an ASCII name, Affinity first narrows the lists by name (``term``, a
      case-insensitive substring match) and the exact comparison is made here. If that
      yields no match, every list is checked, so a difference in how Affinity folds case
      or whitespace can't hide a list. Non-ASCII names always check every list.
    - Unlike `resolve()`, this does not cache results.
    """
    term = name.strip()
    if term and term.isascii():
        candidates = [item async for page in self.pages(term=term) for item in page.data]
        matches = _exact_name_matches(candidates, name, list_type)
        if matches:
            return matches
    return _exact_name_matches([item async for item in self.all()], name, list_type)

saved_views_all(list_id: ListId) -> AsyncIterator[SavedView] async

Iterate all saved views for a list.

Source code in affinity/services/lists.py
1901
1902
1903
1904
1905
async def saved_views_all(self, list_id: ListId) -> AsyncIterator[SavedView]:
    """Iterate all saved views for a list."""
    async for page in self.saved_views_pages(list_id):
        for item in page.data:
            yield item

saved_views_pages(list_id: ListId, *, limit: int | None = None, cursor: str | None = None) -> AsyncIterator[PaginatedResponse[SavedView]] async

Iterate saved view pages, yielding PaginatedResponse[SavedView].

Source code in affinity/services/lists.py
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
async def saved_views_pages(
    self,
    list_id: ListId,
    *,
    limit: int | None = None,
    cursor: str | None = None,
) -> AsyncIterator[PaginatedResponse[SavedView]]:
    """Iterate saved view pages, yielding `PaginatedResponse[SavedView]`."""
    if cursor is not None and limit is not None:
        raise ValueError(
            "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
            "Start a new pagination sequence without a cursor to change parameters."
        )
    requested_cursor = cursor
    guard = _CursorGuard(cursor)
    page = (
        await self.get_saved_views(list_id, limit=limit)
        if cursor is None
        else await self.get_saved_views(list_id, cursor=cursor)
    )
    while True:
        yield page
        # Stop at the last page, or when paging can't progress (see _CursorGuard).
        if guard.last_page(requested_cursor, page):
            return
        requested_cursor = page.next_cursor
        page = await self.get_saved_views(list_id, cursor=requested_cursor)

update_field_dropdown_option(list_id: ListId, field_id: AnyFieldId, option_id: int, *, text: str | None = None, rank: int | None = None, color: DropdownOptionColorName | None = None, status_category: DropdownStatusCategory | None = None, win_rate: int | None = None) -> DropdownOption async

Change an option of a list field (only what you pass).

A dropdown option can change its text; ranked-dropdown also rank and color; status-dropdown also status category and win rate. Renaming changes the value shown on every entry that has this option. Returns the option as read back afterwards.

Source code in affinity/services/lists.py
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
async def update_field_dropdown_option(
    self,
    list_id: ListId,
    field_id: AnyFieldId,
    option_id: int,
    *,
    text: str | None = None,
    rank: int | None = None,
    color: DropdownOptionColorName | None = None,
    status_category: DropdownStatusCategory | None = None,
    win_rate: int | None = None,
) -> DropdownOption:
    """Change an option of a list field (only what you pass).

    A ``dropdown`` option can change its text; ``ranked-dropdown`` also rank and color;
    ``status-dropdown`` also status category and win rate. Renaming changes the value shown
    on every entry that has this option. Returns the option as read back afterwards.
    """
    body = update_body(
        text=text,
        rank=rank,
        color=color,
        status_category=status_category,
        win_rate=win_rate,
    )
    await self._client.post(
        option_path(list_id, field_id, option_id),
        json=body,
        min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
    )
    # The API answers 204 with no body: read the option back
    return DropdownOption.model_validate(
        await self._client.get(
            option_path(list_id, field_id, option_id),
            min_api_version=DROPDOWN_OPTION_WRITES_MIN_API_VERSION,
        )
    )