Skip to content

user_avatars

Shared, non-blocking source of round user-avatar pixmaps.

Widgets that show a single user can simply build an :class:AYUserImage. Painters that render many users - table cells, item delegates - need a pixmap on the paint path without touching the network, which is what this cache provides: initials are available immediately, and the real avatar replaces them once it has been downloaded in the background.

UserAvatarCache

Bases: QObject

Cache of round avatar pixmaps keyed by user name and size.

:meth:pixmap never blocks: it returns an initials avatar right away and schedules the real image, emitting :attr:avatar_updated when the download lands so views can repaint.

Source code in client/ayon_core/ui/components/user_avatars.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
class UserAvatarCache(QtCore.QObject):
    """Cache of round avatar pixmaps keyed by user name and size.

    :meth:`pixmap` never blocks: it returns an initials avatar right away
    and schedules the real image, emitting :attr:`avatar_updated` when the
    download lands so views can repaint.
    """

    #: Emitted with the login name once a downloaded avatar replaced the
    #: initials placeholder for that user.
    avatar_updated = QtCore.Signal(str)

    def __init__(self, parent: QtCore.QObject | None = None) -> None:
        super().__init__(parent)
        self._pixmaps: dict[tuple[str, int], QtGui.QPixmap] = {}
        self._sources: dict[str, str] = {}
        self._pending: set[str] = set()

    def clear(self) -> None:
        """Drop every cached pixmap and downloaded source path."""
        self._pixmaps.clear()
        self._sources.clear()

    def pixmap(
        self,
        user_name: str,
        full_name: str,
        size: int,
    ) -> QtGui.QPixmap | None:
        """Return the avatar for *user_name*, scheduling a download once.

        Args:
            user_name: Login name, used as the cache key and for initials.
            full_name: Display name the initials are derived from.
            size: Avatar diameter in logical pixels.

        Returns:
            A rendered pixmap, or ``None`` when there is nothing to draw.
        """
        if not user_name:
            return None
        key = (user_name, int(size))
        pixmap = self._pixmaps.get(key)
        if pixmap is None:
            pixmap = self._render(user_name, full_name, int(size))
            self._pixmaps[key] = pixmap
        self._schedule_fetch(user_name)
        return pixmap

    def _render(
        self, user_name: str, full_name: str, size: int
    ) -> QtGui.QPixmap:
        """Render one avatar through :class:`AYUserImage`.

        The widget is only a renderer here - it is never shown - so the
        avatars stay identical to the ones the rest of the UI draws.
        """
        src = self._sources.get(user_name, "")
        if src and not os.path.exists(src):
            # The image cache is shared by all AYON processes on the
            #   machine, any of them may have evicted the file meanwhile.
            #   Fall back to initials and let it be downloaded again.
            del self._sources[user_name]
            src = ""
        widget = AYUserImage(
            src=src,
            name=user_name,
            full_name=full_name or user_name,
            size=size,
            outline=False,
        )
        pixmap = widget.pxm
        widget.deleteLater()
        return pixmap

    def _schedule_fetch(self, user_name: str) -> None:
        """Queue a one-off background download of *user_name*'s avatar."""
        if user_name in self._sources or user_name in self._pending:
            return
        self._pending.add(user_name)

        def _work() -> str:
            cache = ImageCache.get_instance()

            def _download() -> io.BytesIO:
                stream = _fetch_avatar(user_name)
                if stream is None:
                    raise _NoAvatarError(user_name)
                return stream

            try:
                file_path = cache.get(f"user-avatar/{user_name}", _download)
                # Generated initials may be cached from before they
                #   were skipped on download.
                if file_path and _is_generated_initials(
                    _read_file_head(file_path)
                ):
                    return ""
                return file_path
            except _NoAvatarError:
                return ""
            except Exception:  # noqa: BLE001 - avatars are optional
                log.debug(
                    "Could not fetch avatar for %r", user_name, exc_info=True
                )
                return ""

        def _done(file_path: str) -> None:
            # The cache outlives no view, but a queued download can land
            # after the owning widget was torn down.
            if shiboken.isValid(self):
                self._on_avatar_ready(user_name, file_path)

        get_task_queue().enqueue(
            AsyncTask(
                name=f"fetch_avatar:{user_name}",
                function=_work,
                callback=_done,
                priority=_FETCH_PRIORITY,
                context_id="user-avatars",
                cancellable=False,
            )
        )

    def _on_avatar_ready(self, user_name: str, file_path: str) -> None:
        """Swap the initials placeholder for the downloaded avatar."""
        self._pending.discard(user_name)
        if not file_path:
            # Remember the miss so the download is not retried per repaint.
            self._sources[user_name] = ""
            return
        self._sources[user_name] = file_path
        for key in [k for k in self._pixmaps if k[0] == user_name]:
            del self._pixmaps[key]
        self.avatar_updated.emit(user_name)

clear()

Drop every cached pixmap and downloaded source path.

Source code in client/ayon_core/ui/components/user_avatars.py
127
128
129
130
def clear(self) -> None:
    """Drop every cached pixmap and downloaded source path."""
    self._pixmaps.clear()
    self._sources.clear()

pixmap(user_name, full_name, size)

Return the avatar for user_name, scheduling a download once.

Parameters:

Name Type Description Default
user_name str

Login name, used as the cache key and for initials.

required
full_name str

Display name the initials are derived from.

required
size int

Avatar diameter in logical pixels.

required

Returns:

Type Description
QPixmap | None

A rendered pixmap, or None when there is nothing to draw.

Source code in client/ayon_core/ui/components/user_avatars.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def pixmap(
    self,
    user_name: str,
    full_name: str,
    size: int,
) -> QtGui.QPixmap | None:
    """Return the avatar for *user_name*, scheduling a download once.

    Args:
        user_name: Login name, used as the cache key and for initials.
        full_name: Display name the initials are derived from.
        size: Avatar diameter in logical pixels.

    Returns:
        A rendered pixmap, or ``None`` when there is nothing to draw.
    """
    if not user_name:
        return None
    key = (user_name, int(size))
    pixmap = self._pixmaps.get(key)
    if pixmap is None:
        pixmap = self._render(user_name, full_name, int(size))
        self._pixmaps[key] = pixmap
    self._schedule_fetch(user_name)
    return pixmap

set_avatar_decoration(option, avatar_cache, user_name, full_name=None, size=ITEM_AVATAR_SIZE)

Show the avatar of a user as the decoration of a view item.

Meant to be called from initStyleOption of an item delegate, so the avatar is painted the same way as an icon of the item, to the left of its text. The view should repaint on :attr:UserAvatarCache.avatar_updated.

Parameters:

Name Type Description Default
option QStyleOptionViewItem

Style option of the painted item.

required
avatar_cache UserAvatarCache

Cache the avatar is taken from.

required
user_name str | None

Login name of the user, nothing is changed without it.

required
full_name str | None

Display name the initials are derived from.

None
size int

Avatar diameter in logical pixels.

ITEM_AVATAR_SIZE
Source code in client/ayon_core/ui/components/user_avatars.py
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
def set_avatar_decoration(
    option: QtWidgets.QStyleOptionViewItem,
    avatar_cache: UserAvatarCache,
    user_name: str | None,
    full_name: str | None = None,
    size: int = ITEM_AVATAR_SIZE,
) -> None:
    """Show the avatar of a user as the decoration of a view item.

    Meant to be called from ``initStyleOption`` of an item delegate, so
    the avatar is painted the same way as an icon of the item, to the
    left of its text. The view should repaint on
    :attr:`UserAvatarCache.avatar_updated`.

    Args:
        option: Style option of the painted item.
        avatar_cache: Cache the avatar is taken from.
        user_name: Login name of the user, nothing is changed without it.
        full_name: Display name the initials are derived from.
        size: Avatar diameter in logical pixels.
    """
    if not user_name:
        return

    pix_size = int(size * _get_max_screen_pixel_ratio())
    pixmap = avatar_cache.pixmap(user_name, full_name or "", pix_size)
    if pixmap is None or pixmap.isNull():
        return
    option.icon = QtGui.QIcon(pixmap)
    option.decorationSize = QtCore.QSize(size, size)
    option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration