diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3f73bae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses +[Semantic Versioning](https://semver.org/). + +## [1.0.0] + +Validated against API contract 2.0.0. + +### BREAKING + +- `faces.active_liveness` no longer returns a liveness token: the API's + stateless check stopped issuing them. `ActiveLivenessResult.liveness_token` + and `ActiveLivenessResult.liveness_token_expires_at` are removed. + + Migration: create a session, show its challenges, complete it with the frames, + then enrol with the token it returns. + + ```python + session = client.faces.create_liveness_session(collection_id) + # show session.challenges in order, capture frames while they are performed + result = client.faces.complete_liveness_session(collection_id, session.session_id, frames, mirrored=True) + if result.liveness_token: + client.faces.register(collection_id, image, external_id, liveness_token=result.liveness_token) + ``` + +### Added + +- `faces.create_liveness_session(collection_id)` returns a `LivenessSession` + (`session_id`, `challenges`: `blink` / `turn_left` / `turn_right` in order, + `expires_at`). +- `faces.complete_liveness_session(collection_id, session_id, frames, mirrored=False)` + returns a `LivenessSessionResult`: the active-liveness fields, `steps` + (`LivenessStep`: `type`, `passed`) and, when it passed, `liveness_token` and + `liveness_token_expires_at`. It is retried on a 429 only, never on a network + error or a 5xx, because a session is judged once. +- Error code `LIVENESS_SESSION_INVALID` (422), raised as `LiveXFaceApiError`, + when a session is unknown, expired, already submitted or bound to another + collection. + +### Changed + +- Pinned API contract 2.0.0 (`contract/openapi-2.0.0.json`, + `livexface.CONTRACT_VERSION`). diff --git a/CONTRACT_VERSION b/CONTRACT_VERSION index 3eefcb9..227cea2 100644 --- a/CONTRACT_VERSION +++ b/CONTRACT_VERSION @@ -1 +1 @@ -1.0.0 +2.0.0 diff --git a/README.md b/README.md index b123b04..68eb53b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Official Python SDK for [LiveXFace](https://github.com/livexface/livexface-pytho pip install livexface ``` -Validated against API contract 1.0.0 (`/openapi.json` `info.version`). The test suite calls every client method against the contract committed in `contract/` and fails if a method, path or required field is not in it; to move to a new contract, copy the release asset `openapi-.json` into `contract/`, then update `CONTRACT_VERSION` and `livexface.CONTRACT_VERSION`. +Validated against API contract 2.0.0 (`/openapi.json` `info.version`). The test suite calls every client method against the contract committed in `contract/` and fails if a method, path or required field is not in it; to move to a new contract, copy the release asset `openapi-.json` into `contract/`, then update `CONTRACT_VERSION` and `livexface.CONTRACT_VERSION`. ## Quick Start @@ -61,27 +61,53 @@ liveness = client.faces.liveness( print(f"Live: {liveness.is_live}, Score: {liveness.liveness_score:.3f}") ``` -## Active Liveness and Enrolment +## Liveness Sessions and Enrolment -A collection can require a liveness check before a face is enrolled. Run an -active check over 5 to 50 frames captured in order (the user blinks and turns -their head), then pass the token it returns when you register. The token is -single-use, valid for 5 minutes, and bound to the collection. +A collection can require a liveness check before a face is enrolled. The +token for that comes from a liveness session: the server picks the steps the +person must perform, in order, and the frames must show them. + +1. Create a session. `challenges` lists the steps: `blink`, `turn_left` or + `turn_right` (the person's own left and right). Submit before `expires_at` + (60 seconds by default). +2. Show each prompt and capture 5 to 50 frames, in order, while the person + performs them. +3. Complete the session with the frames. Pass `mirrored=True` when they are + horizontally mirrored, as a selfie preview is. +4. If it passed, register with its `liveness_token`: single-use, valid for 5 + minutes, and bound to the collection. ```python -frames = [open(f"frame_{i}.jpg", "rb") for i in range(10)] -check = client.faces.active_liveness("collection-uuid", frames) -print(f"Live: {check.is_live}, Blink: {check.blink.passed}, Head turn: {check.head_turn.passed}") +session = client.faces.create_liveness_session("collection-uuid") +prompts = {"blink": "Blink", "turn_left": "Turn your head to your left", "turn_right": "Turn your head to your right"} +for step in session.challenges: + print(prompts[step]) # show the prompt, keep capturing frames + +frames = [open(f"frame_{i}.jpg", "rb") for i in range(25)] +result = client.faces.complete_liveness_session("collection-uuid", session.session_id, frames, mirrored=True) +print(f"Live: {result.is_live}, Steps: {[(s.type, s.passed) for s in result.steps]}") -if check.liveness_token: +if result.liveness_token: face = client.faces.register( collection_id="collection-uuid", image=open("photo.jpg", "rb"), external_id="user_123", - liveness_token=check.liveness_token, + liveness_token=result.liveness_token, ) ``` +A session is judged once. Any submission except one with fewer than 5 frames +(400 `IMAGE_REQUIRED`, the session is kept) uses it up, so +`complete_liveness_session` is never retried on a network error or a 5xx; +create a new session instead. Completing fails with `LIVENESS_SESSION_INVALID` +(422) when the session is unknown, expired, already submitted or bound to +another collection, and with `SERVICE_BUSY` (503) when the engine is +saturated; create a new session for either. + +`active_liveness(collection_id, frames)` still checks frames for a blink, a +head turn and passive anti-spoofing, but it returns a verdict only and no +token. + Batch items accept a `liveness_token` key too. Enrolment fails with `LIVENESS_TOKEN_REQUIRED` (400) when the collection requires a token and none was sent, `LIVENESS_TOKEN_INVALID` (422) when it is expired, used or for @@ -149,6 +175,8 @@ after the first): a 429 or 503 is retried after its `Retry-After`, capped at none. A network error or another 5xx is retried only for reads, deletions and calls that carry an idempotency key; other 4xx are never retried. Enrolment and batch calls send one key on every attempt, generating it when you give none. +`complete_liveness_session` is retried on a 429 only, since a session is +judged once. ```python from livexface import LiveXFace, LiveXFaceApiError, new_idempotency_key diff --git a/contract/openapi-1.0.0.json b/contract/openapi-2.0.0.json similarity index 91% rename from contract/openapi-1.0.0.json rename to contract/openapi-2.0.0.json index 3743d0d..7cdb4e6 100644 --- a/contract/openapi-1.0.0.json +++ b/contract/openapi-2.0.0.json @@ -1,37 +1,6 @@ { "components": { "schemas": { - "github_com_livexface_backend_internal_domain_entity.ActiveLivenessResult": { - "properties": { - "challenges": { - "additionalProperties": { - "additionalProperties": true, - "type": "object" - }, - "type": "object" - }, - "framesAnalyzed": { - "type": "integer" - }, - "framesWithFace": { - "type": "integer" - }, - "isLive": { - "type": "boolean" - }, - "livenessToken": { - "description": "Set only when the check passed and the token could be stored.", - "type": "string" - }, - "livenessTokenExpiresAt": { - "type": "string" - }, - "overallScore": { - "type": "number" - } - }, - "type": "object" - }, "github_com_livexface_backend_internal_domain_entity.BatchJobResult": { "properties": { "error": { @@ -88,6 +57,17 @@ }, "type": "object" }, + "github_com_livexface_backend_internal_domain_entity.LivenessStep": { + "properties": { + "passed": { + "type": "boolean" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, "github_com_livexface_backend_internal_domain_entity.PlanFeature": { "properties": { "included": { @@ -334,6 +314,30 @@ }, "type": "object" }, + "internal_delivery_http_handler.activeLivenessResponse": { + "properties": { + "challenges": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object" + }, + "framesAnalyzed": { + "type": "integer" + }, + "framesWithFace": { + "type": "integer" + }, + "isLive": { + "type": "boolean" + }, + "overallScore": { + "type": "number" + } + }, + "type": "object" + }, "internal_delivery_http_handler.apiKeyDTO": { "properties": { "allowedIps": { @@ -1291,6 +1295,15 @@ ], "type": "object" }, + "internal_delivery_http_handler.livenessChallengeDTO": { + "properties": { + "type": { + "example": "turn_left", + "type": "string" + } + }, + "type": "object" + }, "internal_delivery_http_handler.livenessResponse": { "properties": { "faceCount": { @@ -1308,6 +1321,60 @@ }, "type": "object" }, + "internal_delivery_http_handler.livenessSessionResponse": { + "properties": { + "challenges": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessChallengeDTO" + }, + "type": "array" + }, + "expiresAt": { + "type": "string" + }, + "sessionId": { + "example": "lvs_3q2-…", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.livenessSessionResultResponse": { + "properties": { + "challenges": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object" + }, + "framesAnalyzed": { + "type": "integer" + }, + "framesWithFace": { + "type": "integer" + }, + "isLive": { + "type": "boolean" + }, + "livenessToken": { + "type": "string" + }, + "livenessTokenExpiresAt": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.LivenessStep" + }, + "type": "array" + } + }, + "type": "object" + }, "internal_delivery_http_handler.loginRequest": { "properties": { "email": { @@ -1964,13 +2031,13 @@ }, "termsOfService": "http://swagger.io/terms/", "title": "LiveXFace — Face Recognition API as a Service", - "version": "1.0.0" + "version": "2.0.0" }, "openapi": "3.0.3", "paths": { "/collections/{collection_id}/active-liveness": { "post": { - "description": "Judge a sequence of frames (blink, head turn and the passive model). Send the frames in order as frame_0, frame_1, … up to frame_49; at least 5 are required. A passed check returns a single-use livenessToken, valid for 5 minutes and bound to this collection, for enrolling into a collection that requires liveness.", + "description": "Judge a sequence of frames (blink, head turn and the passive model). Send the frames in order as frame_0, frame_1, … up to frame_49; at least 5 are required. This stateless check returns a verdict only and issues no liveness token; to enrol into a collection that requires liveness, complete a liveness session instead.", "parameters": [ { "description": "Collection ID (UUID)", @@ -2014,7 +2081,7 @@ { "properties": { "data": { - "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.ActiveLivenessResult" + "$ref": "#/components/schemas/internal_delivery_http_handler.activeLivenessResponse" } }, "type": "object" @@ -2163,7 +2230,7 @@ "APIKeyAuth": [] } ], - "summary": "Active liveness check", + "summary": "Active liveness check (verdict only)", "tags": [ "Face Recognition" ] @@ -4660,6 +4727,381 @@ ] } }, + "/collections/{collection_id}/liveness-sessions": { + "post": { + "description": "Start a liveness session: the server picks the steps the person must perform, in order (one blink and one or two head turns, turn_left and turn_right meaning the person's own left and right). Show them to the person, capture frames while they perform them, and submit the frames to the session before expiresAt (60 seconds by default). The session is bound to this collection.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessSessionResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Created" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Create a liveness session", + "tags": [ + "Face Recognition" + ] + } + }, + "/collections/{collection_id}/liveness-sessions/{session_id}": { + "post": { + "description": "Submit the frames for a liveness session, once, before it expires. Every step must appear in the frames in the order given, and the blink and passive rules of the active check must hold. A pass returns a single-use livenessToken, valid for 5 minutes and bound to this collection, for enrolling with liveness_token. The session is used up by any submission except one with fewer than 5 frames.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Session ID from creating the session", + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "frame_0": { + "description": "First frame; continue with frame_1 … frame_49 (at least 5 frames)", + "format": "binary", + "type": "string", + "x-formData-name": "frame_0" + }, + "mirrored": { + "default": false, + "description": "true when the frames are horizontally mirrored, as a selfie preview is", + "type": "boolean", + "x-formData-name": "mirrored" + } + }, + "required": [ + "frame_0" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessSessionResultResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IMAGE_REQUIRED: fewer than 5 readable frames (the session is kept); BAD_REQUEST: mirrored is not true or false" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "LIVENESS_SESSION_INVALID: unknown, expired, already submitted, or bound to another organization or collection" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; create a new session and retry" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Complete a liveness session", + "tags": [ + "Face Recognition" + ] + } + }, "/collections/{collection_id}/verify": { "post": { "description": "Compare an uploaded image against a stored face (by face_id) or a second uploaded image (reference_image). Returns match/no-match with confidence score.", diff --git a/livexface/__init__.py b/livexface/__init__.py index 7869e49..a183e25 100644 --- a/livexface/__init__.py +++ b/livexface/__init__.py @@ -10,6 +10,9 @@ LivenessResult, LivenessChallenge, ActiveLivenessResult, + LivenessSession, + LivenessStep, + LivenessSessionResult, BatchResponse, BatchDeleteResponse, FaceAttributes, @@ -18,9 +21,9 @@ BatchJob, ) -__version__ = "0.1.0" +__version__ = "1.0.0" # The API contract (`info.version` of /openapi.json) this release is validated against. -CONTRACT_VERSION = "1.0.0" +CONTRACT_VERSION = "2.0.0" __all__ = [ "CONTRACT_VERSION", "LiveXFace", @@ -34,6 +37,9 @@ "LivenessResult", "LivenessChallenge", "ActiveLivenessResult", + "LivenessSession", + "LivenessStep", + "LivenessSessionResult", "BatchResponse", "BatchDeleteResponse", "FaceAttributes", diff --git a/livexface/client.py b/livexface/client.py index f454984..6c42d11 100644 --- a/livexface/client.py +++ b/livexface/client.py @@ -20,6 +20,8 @@ IdentifyResult, LivenessResult, ActiveLivenessResult, + LivenessSession, + LivenessSessionResult, BatchResponse, BatchDeleteResponse, AttributesResult, @@ -61,6 +63,10 @@ def _to_bytes_tuple(src: ImageInput, filename: str = "image.jpg") -> tuple[str, return filename, src.read(), "image/jpeg" +def _frame_files(frames: Sequence[ImageInput]) -> dict[str, Any]: + return {f"frame_{i}": _to_bytes_tuple(frame, f"frame_{i}.jpg") for i, frame in enumerate(frames)} + + class LiveXFace: """ Main client for the LiveXFace recognition API. @@ -99,6 +105,8 @@ def __init__( ``Retry-After`` (or an exponential backoff with jitter); a network error or another 5xx only for GET, PATCH and DELETE calls and for calls that carry an idempotency key. Other 4xx are never retried. + :meth:`FacesResource.complete_liveness_session` is retried on a + 429 only: a session is judged once. :param max_retry_delay: Upper bound, in seconds, of one wait between attempts. :param sleep: Called with the delay before each retry; tests replace it. @@ -115,18 +123,27 @@ def __init__( self.faces = FacesResource(self) def _request( - self, method: str, endpoint: str, idempotency_key: str | None = None, **kwargs: Any + self, + method: str, + endpoint: str, + idempotency_key: str | None = None, + single_use: bool = False, + **kwargs: Any, ) -> Any: + """:param single_use: The request uses up a server-side resource once it + reaches the handler, whatever the outcome (a liveness session), so only + a 429, which is rejected before that, is retried.""" if idempotency_key: kwargs["headers"] = {"Idempotency-Key": idempotency_key} # A keyed request is safe to repeat: the API replays the first answer. safe = method in _SAFE_METHODS or bool(idempotency_key) + busy = (429,) if single_use else (429, 503) attempt = 0 while True: try: return self._send(method, endpoint, **kwargs) except LiveXFaceApiError as exc: - retryable = exc.status_code in (429, 503) or (exc.status_code >= 500 and safe) + retryable = exc.status_code in busy or (exc.status_code >= 500 and safe) if not retryable or attempt >= self.max_retries: raise wait: float | None = exc.retry_after @@ -207,8 +224,8 @@ def register( ) -> Face: """Register a face in a collection. - :param liveness_token: Token from a passed :meth:`active_liveness` - check. Required when the collection requires liveness on enrolment; + :param liveness_token: Token from a passed liveness session (see + :meth:`complete_liveness_session`). Required when the collection requires liveness on enrolment; single-use, valid for 5 minutes, and bound to the collection. :param idempotency_key: Sent as ``Idempotency-Key``; repeating the call with the same key within 24 hours replays the first answer instead @@ -338,19 +355,58 @@ def active_liveness( Active liveness — check a sequence of frames for a blink, a head turn and passive anti-spoofing. - Send 5 to 50 frames (JPEG/PNG) captured in order. When the check - passes, the result carries a ``liveness_token`` to pass to - :meth:`register` or a batch entry: single-use, valid for 5 minutes, - and bound to this collection. + Send 5 to 50 frames (JPEG/PNG) captured in order. This stateless check + returns a verdict only and issues no liveness token; to enrol into a + collection that requires liveness, use :meth:`create_liveness_session` + and :meth:`complete_liveness_session`. """ - files: dict[str, Any] = {} - for i, frame in enumerate(frames): - files[f"frame_{i}"] = _to_bytes_tuple(frame, f"frame_{i}.jpg") resp = self._c._request( - "POST", f"/collections/{collection_id}/active-liveness", files=files + "POST", f"/collections/{collection_id}/active-liveness", files=_frame_files(frames) ) return ActiveLivenessResult.from_dict(resp) + def create_liveness_session(self, collection_id: str) -> LivenessSession: + """ + Start a liveness session bound to this collection. The server picks the + steps the person must perform, in order (``blink``, ``turn_left``, + ``turn_right``; the person's own left and right). Show them, capture + frames while they are performed, and submit the frames with + :meth:`complete_liveness_session` before ``expires_at``. + """ + resp = self._c._request("POST", f"/collections/{collection_id}/liveness-sessions") + return LivenessSession.from_dict(resp) + + def complete_liveness_session( + self, + collection_id: str, + session_id: str, + frames: Sequence[ImageInput], + mirrored: bool = False, + ) -> LivenessSessionResult: + """ + Submit 5 to 50 frames (JPEG/PNG), captured in order, for a liveness + session. A session is judged once: any submission except one with + fewer than 5 frames uses it up, so this call is never retried on a + network error or a 5xx. When the session passes, the result carries a + ``liveness_token`` to pass to :meth:`register` or a batch entry: + single-use, valid for 5 minutes, and bound to this collection. + + :param mirrored: True when the frames are horizontally mirrored, as a + selfie preview is. + + Raises :class:`LiveXFaceApiError` with code ``LIVENESS_SESSION_INVALID`` + (422) when the session is unknown, expired, already submitted or bound + to another collection; create a new session then. + """ + resp = self._c._request( + "POST", + f"/collections/{collection_id}/liveness-sessions/{session_id}", + single_use=True, + files=_frame_files(frames), + data={"mirrored": "true" if mirrored else "false"}, + ) + return LivenessSessionResult.from_dict(resp) + def compare( self, image1: ImageInput, @@ -381,7 +437,7 @@ def batch_register( Each item must have ``image`` (ImageInput) and ``external_id`` (str). Optional ``metadata`` dict and ``liveness_token`` (str, from - :meth:`active_liveness`) are also supported. ``idempotency_key`` works + :meth:`complete_liveness_session`) are also supported. ``idempotency_key`` works as in :meth:`register`. Example:: @@ -444,7 +500,7 @@ def batch_register_async( Each item must have ``image`` (ImageInput) and ``external_id`` (str). Optional ``metadata`` dict and ``liveness_token`` (str, from - :meth:`active_liveness`) are also supported. ``idempotency_key`` works + :meth:`complete_liveness_session`) are also supported. ``idempotency_key`` works as in :meth:`register`. """ files: dict[str, Any] = {} diff --git a/livexface/types.py b/livexface/types.py index 6994de9..c1d7c5b 100644 --- a/livexface/types.py +++ b/livexface/types.py @@ -134,10 +134,10 @@ def from_dict(cls, d: dict[str, Any]) -> "LivenessChallenge": @dataclass class ActiveLivenessResult: - """Result of an active (multi-frame) liveness check. + """Result of a stateless active (multi-frame) liveness check. - ``liveness_token`` and ``liveness_token_expires_at`` (RFC 3339) are set only - when the check passed. + A verdict only: it carries no liveness token. To enrol into a collection + that requires liveness, complete a liveness session instead. """ is_live: bool @@ -147,8 +147,6 @@ class ActiveLivenessResult: blink: LivenessChallenge head_turn: LivenessChallenge passive_antispoof: LivenessChallenge - liveness_token: str | None = None - liveness_token_expires_at: str | None = None @classmethod def from_dict(cls, d: dict[str, Any]) -> "ActiveLivenessResult": @@ -161,6 +159,62 @@ def from_dict(cls, d: dict[str, Any]) -> "ActiveLivenessResult": blink=LivenessChallenge.from_dict(ch.get("blink") or {}), head_turn=LivenessChallenge.from_dict(ch.get("headTurn") or {}), passive_antispoof=LivenessChallenge.from_dict(ch.get("passiveAntispoof") or {}), + ) + + +@dataclass +class LivenessSession: + """A liveness session: the steps the person must perform, in order. + + Each entry of ``challenges`` is ``blink``, ``turn_left`` or ``turn_right`` + (the person's own left and right). Submit the frames with + :meth:`~livexface.client.FacesResource.complete_liveness_session` before + ``expires_at`` (RFC 3339). + """ + + session_id: str + challenges: list[str] + expires_at: str + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "LivenessSession": + return cls( + session_id=d["sessionId"], + challenges=[c.get("type", "") for c in d.get("challenges") or []], + expires_at=d.get("expiresAt", ""), + ) + + +@dataclass +class LivenessStep: + """One step of a completed liveness session and whether it was performed.""" + + type: str + passed: bool + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "LivenessStep": + return cls(type=d.get("type", ""), passed=bool(d.get("passed", False))) + + +@dataclass +class LivenessSessionResult(ActiveLivenessResult): + """Result of completing a liveness session: the active check's fields plus + ``steps``, the session's challenges in order. + + ``liveness_token`` and ``liveness_token_expires_at`` (RFC 3339) are set only + when the session passed. + """ + + steps: list[LivenessStep] = field(default_factory=list) + liveness_token: str | None = None + liveness_token_expires_at: str | None = None + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "LivenessSessionResult": + return cls( + **vars(ActiveLivenessResult.from_dict(d)), + steps=[LivenessStep.from_dict(s) for s in d.get("steps") or []], liveness_token=d.get("livenessToken") or None, liveness_token_expires_at=d.get("livenessTokenExpiresAt") or None, ) diff --git a/pyproject.toml b/pyproject.toml index 161f202..cd374b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "livexface" -version = "0.1.0" +version = "1.0.0" description = "Official Python SDK for LiveXFace — Face Recognition as a Service" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_contract.py b/tests/test_contract.py index a2c9b3e..7269764 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -52,6 +52,12 @@ "faces.identify": (("c1", IMG), {"top_k": 3, "threshold": 0.5}, DATA), "faces.liveness": (("c1", IMG), {}, DATA), "faces.active_liveness": (("c1", [IMG] * 5), {}, DATA), + "faces.create_liveness_session": ( + ("c1",), + {}, + {"sessionId": "lvs_1", "challenges": [{"type": "blink"}], "expiresAt": "2026-09-28T00:01:00Z"}, + ), + "faces.complete_liveness_session": (("c1", "lvs_1", [IMG] * 5), {"mirrored": True}, DATA), "faces.compare": ((IMG, IMG), {"threshold": 0.5}, DATA), "faces.batch_register": (("c1", ITEMS), {"idempotency_key": "key-2"}, DATA), "faces.batch_delete": (("c1", ["f1", "f2"]), {}, DATA), diff --git a/tests/test_liveness.py b/tests/test_liveness.py index 8eaa058..805988a 100644 --- a/tests/test_liveness.py +++ b/tests/test_liveness.py @@ -1,4 +1,4 @@ -"""Tests for active liveness and liveness-token enrolment.""" +"""Tests for active liveness, liveness sessions and liveness-token enrolment.""" from __future__ import annotations @@ -8,7 +8,7 @@ import pytest -from livexface import ActiveLivenessResult, LiveXFace +from livexface import ActiveLivenessResult, LiveXFace, LiveXFaceApiError, LivenessSessionResult BASE = "http://api.test/api/v1" @@ -29,7 +29,7 @@ def client() -> LiveXFace: return LiveXFace(api_key="lxf_test", base_url=BASE) -def test_active_liveness_sends_frames_and_parses_token(client: LiveXFace, mocker: Any) -> None: +def test_active_liveness_sends_frames_and_has_no_token(client: LiveXFace, mocker: Any) -> None: data = { "isLive": True, "overallScore": 0.93, @@ -40,8 +40,6 @@ def test_active_liveness_sends_frames_and_parses_token(client: LiveXFace, mocker "headTurn": {"passed": True, "available": True, "yawRange": 31.5}, "passiveAntispoof": {"passed": True, "available": True, "score": 0.97}, }, - "livenessToken": "tok_abc", - "livenessTokenExpiresAt": "2026-09-28T10:05:00Z", } req = mocker.patch.object(client._session, "request", return_value=_resp(data)) @@ -55,8 +53,8 @@ def test_active_liveness_sends_frames_and_parses_token(client: LiveXFace, mocker assert files["frame_3"][1] == b"img3" assert isinstance(result, ActiveLivenessResult) assert result.is_live is True - assert result.liveness_token == "tok_abc" - assert result.liveness_token_expires_at == "2026-09-28T10:05:00Z" + assert not hasattr(result, "liveness_token") + assert not hasattr(result, "liveness_token_expires_at") assert result.blink.passed is True assert result.blink.details == {"blinkCount": 2} assert result.head_turn.details["yawRange"] == 31.5 @@ -79,13 +77,111 @@ def test_active_liveness_failed_has_no_token(client: LiveXFace, mocker: Any) -> result = client.faces.active_liveness("c1", [b"x"] * 5) assert result.is_live is False - assert result.liveness_token is None - assert result.liveness_token_expires_at is None assert result.blink.passed is False assert result.head_turn.passed is None assert result.head_turn.available is False +def test_create_liveness_session_returns_challenges(client: LiveXFace, mocker: Any) -> None: + data = { + "sessionId": "lvs_1", + "challenges": [{"type": "turn_left"}, {"type": "blink"}, {"type": "turn_right"}], + "expiresAt": "2026-09-28T10:01:00Z", + } + req = mocker.patch.object(client._session, "request", return_value=_resp(data, 201)) + + session = client.faces.create_liveness_session("c1") + + assert req.call_args.args == ("POST", f"{BASE}/collections/c1/liveness-sessions") + assert "files" not in req.call_args.kwargs and "data" not in req.call_args.kwargs + assert "headers" not in req.call_args.kwargs # no idempotency key + assert session.session_id == "lvs_1" + assert session.challenges == ["turn_left", "blink", "turn_right"] + assert session.expires_at == "2026-09-28T10:01:00Z" + + +SESSION_PASSED = { + "isLive": True, + "overallScore": 0.95, + "framesAnalyzed": 25, + "framesWithFace": 25, + "challenges": { + "blink": {"passed": True, "available": True}, + "headTurn": {"passed": True, "available": True}, + "passiveAntispoof": {"passed": True, "available": True}, + }, + "steps": [{"type": "turn_left", "passed": True}, {"type": "blink", "passed": True}], + "livenessToken": "tok_abc", + "livenessTokenExpiresAt": "2026-09-28T10:05:00Z", +} + + +@pytest.mark.parametrize(("mirrored", "sent"), [(True, "true"), (False, "false")]) +def test_complete_liveness_session_sends_frames_and_mirrored_and_parses_token( + client: LiveXFace, mocker: Any, mirrored: bool, sent: str +) -> None: + req = mocker.patch.object(client._session, "request", return_value=_resp(SESSION_PASSED)) + + result = client.faces.complete_liveness_session("c1", "lvs_1", [b"img%d" % i for i in range(6)], mirrored=mirrored) + + assert req.call_args.args == ("POST", f"{BASE}/collections/c1/liveness-sessions/lvs_1") + files = req.call_args.kwargs["files"] + assert list(files) == [f"frame_{i}" for i in range(6)] + assert files["frame_3"][1] == b"img3" + assert req.call_args.kwargs["data"] == {"mirrored": sent} + assert "headers" not in req.call_args.kwargs # no idempotency key + assert isinstance(result, LivenessSessionResult) + assert result.is_live is True + assert result.blink.passed is True + assert [(s.type, s.passed) for s in result.steps] == [("turn_left", True), ("blink", True)] + assert result.liveness_token == "tok_abc" + assert result.liveness_token_expires_at == "2026-09-28T10:05:00Z" + + +def test_complete_liveness_session_defaults_to_not_mirrored(client: LiveXFace, mocker: Any) -> None: + req = mocker.patch.object(client._session, "request", return_value=_resp(SESSION_PASSED)) + + client.faces.complete_liveness_session("c1", "lvs_1", [b"x"] * 5) + + assert req.call_args.kwargs["data"] == {"mirrored": "false"} + + +def test_failed_liveness_session_has_no_token(client: LiveXFace, mocker: Any) -> None: + data = { + **SESSION_PASSED, + "isLive": False, + "steps": [{"type": "turn_left", "passed": False}, {"type": "blink", "passed": True}], + } + del data["livenessToken"], data["livenessTokenExpiresAt"] + mocker.patch.object(client._session, "request", return_value=_resp(data)) + + result = client.faces.complete_liveness_session("c1", "lvs_1", [b"x"] * 5) + + assert result.is_live is False + assert result.steps[0].passed is False + assert result.liveness_token is None + assert result.liveness_token_expires_at is None + + +def test_invalid_liveness_session_raises_typed_error(client: LiveXFace, mocker: Any) -> None: + r = MagicMock() + r.status_code = 422 + r.ok = False + r.json.return_value = { + "success": False, + "requestId": "r-9", + "error": {"code": "LIVENESS_SESSION_INVALID", "message": "liveness session is invalid or expired"}, + } + mocker.patch.object(client._session, "request", return_value=r) + + with pytest.raises(LiveXFaceApiError) as exc: + client.faces.complete_liveness_session("c1", "lvs_gone", [b"x"] * 5) + + assert exc.value.code == "LIVENESS_SESSION_INVALID" + assert exc.value.status_code == 422 + assert exc.value.request_id == "r-9" + + def test_register_sends_liveness_token_when_given(client: LiveXFace, mocker: Any) -> None: req = mocker.patch.object(client._session, "request", return_value=_resp(FACE)) diff --git a/tests/test_retries.py b/tests/test_retries.py index 7f13d0b..662cb3e 100644 --- a/tests/test_retries.py +++ b/tests/test_retries.py @@ -168,6 +168,44 @@ def test_unkeyed_post_is_not_retried_on_network_error_or_500(api: FakeAPI, first assert len(api.requests) == 1 +@pytest.mark.parametrize("first", [DROP, _err(500, "INTERNAL_ERROR"), _err(503, "SERVICE_BUSY")]) +def test_liveness_session_completion_is_not_retried(api: FakeAPI, first: Any) -> None: + # The session is used up once the request reaches the API; a retry would + # only get LIVENESS_SESSION_INVALID and hide the real failure. + api.replies = [first, _ok(200, {"isLive": True})] + + with pytest.raises((LiveXFaceNetworkError, LiveXFaceApiError)): + _client(api, [], max_retries=3).faces.complete_liveness_session("c1", "lvs_1", [b"img"] * 5) + + assert len(api.requests) == 1 + assert api.keys() == [None] + + +def test_busy_liveness_session_completion_surfaces_the_503(api: FakeAPI) -> None: + api.replies = [_err(503, "SERVICE_BUSY", {"Retry-After": "1"}), _ok(200, {"isLive": True})] + sleeps: list[float] = [] + + with pytest.raises(LiveXFaceApiError) as exc: + _client(api, sleeps, max_retries=3).faces.complete_liveness_session("c1", "lvs_1", [b"img"] * 5) + + assert exc.value.status_code == 503 + assert exc.value.code == "SERVICE_BUSY" + assert len(api.requests) == 1 + assert sleeps == [] + + +def test_liveness_session_completion_is_retried_on_429(api: FakeAPI) -> None: + # A 429 is rejected before the session is touched. + api.replies = [_err(429, "RATE_LIMIT_EXCEEDED", {"Retry-After": "1"}), _ok(200, {"isLive": True})] + sleeps: list[float] = [] + + result = _client(api, sleeps, max_retries=1).faces.complete_liveness_session("c1", "lvs_1", [b"img"] * 5) + + assert result.is_live is True + assert len(api.requests) == 2 + assert sleeps == [1] + + def test_unkeyed_post_is_retried_on_503(api: FakeAPI) -> None: api.replies = [_err(503, "SERVICE_BUSY"), _ok(200, {"matches": []})] sleeps: list[float] = []