diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 034fcee..7db6e0c 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -23,6 +23,7 @@ const ( kvKeyUsernamePrefix = "v1-user.username." kvKeyEmailPrefix = "v1-user.email." kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." + kvKeyPrimaryEmailPrefix = "v1-user.primary-email." // v1-objects KV key prefixes as replicated by Meltano. v1MergedUserKVPrefix = "salesforce-merged_user." @@ -54,12 +55,37 @@ var ( deleteIndexKeyFn = deleteIndexKey ) -// toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 -// key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. -// NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic -// transposition. RawURLEncoding (no padding) keeps keys opaque and short. +// handleMergedUserDelete dependencies, split out so tests can inject fakes +// without needing a live NATS connection. +var ( + publishUserDeletedEventFn = publishUserDeletedEvent + getPrimaryEmailForUserFn = getCachedPrimaryEmailForUser +) + +// readMappingsKVValueFn reads a raw value from v1-mappings. Swappable in tests. +var readMappingsKVValueFn = func(ctx context.Context, key string) ([]byte, error) { + entry, err := mappingsKV.Get(ctx, key) + if err != nil { + return nil, err + } + return entry.Value(), nil +} + +// lookupPrimaryEmailForUserFn is the live alternate-email lookup used when the +// primary-email cache misses. Swappable in tests. +var lookupPrimaryEmailForUserFn = getPrimaryEmailForUser + +// normalizeKVSegment normalizes a user-provided string for NATS KV key segments: +// TrimSpace → ToLower → NFC. NFC unifies decomposed/precomposed Unicode +// (e.g. n\u0303 ≡ ñ) without semantic transposition. +func normalizeKVSegment(s string) string { + return norm.NFC.String(strings.ToLower(strings.TrimSpace(s))) +} + +// toKVKey encodes a normalized string as a URL-safe base64 key segment +// (RawURLEncoding, no padding) safe for NATS KV. func toKVKey(s string) string { - s = norm.NFC.String(strings.ToLower(strings.TrimSpace(s))) + s = normalizeKVSegment(s) if s == "" { return "" } @@ -117,8 +143,8 @@ func handleMergedUserUpdate(ctx context.Context, key string, v1Data map[string]a // handleMergedUserDelete processes deletion of a merged user record: deletes // the username -> SFID secondary index so future lookups do not resolve a -// deleted user. Soft deletes and hard KV deletes both arrive here; v1Data is -// nil for a hard KV delete. +// deleted user, then scrubs the username from v2 committee data. +// Soft deletes and hard KV deletes both arrive here; v1Data is nil for a hard KV delete. // Returns true if the operation should be retried, false otherwise. func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data map[string]any) bool { if v1Data == nil { @@ -127,6 +153,10 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // always carry a payload, so log a warning if we see this. logger.With("key", key, "user_sfid", userSfid). WarnContext(ctx, "merged_user hard-deleted with no payload; cannot clean up username index") + if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailPrefix+userSfid); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete cached primary email after hard user delete") + } return false } @@ -146,9 +176,97 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // In practice the alternate email rows are deleted before or alongside the user row, so // handleAlternateEmailDelete cleans those up individually — but if the user is deleted // without its alternate emails being deleted first, those entries will be orphaned. + + if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" { + email, emailErr := getPrimaryEmailForUserFn(ctx, userSfid) + if emailErr != nil { + logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid). + DebugContext(ctx, "failed to look up primary email for deleted user; publishing user-deleted event without email") + } else if email == "" { + logger.With("key", key, "user_sfid", userSfid). + DebugContext(ctx, "no primary email found for deleted user; publishing user-deleted event without email") + } + publishUserDeletedEventFn(ctx, key, normalizedUsername, email) + } + + // Best-effort cleanup of the cached primary-email key (even when username is blank). + if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailPrefix+userSfid); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete cached primary email after user delete") + } + return false } +// getCachedPrimaryEmailForUser returns the primary email for a user. It first +// checks the v1-mappings cache written by handleAlternateEmailUpdate (which +// survives alternate-email row deletion), then falls back to the live KV +// lookup. The cache avoids an ordering problem where alternate-email rows are +// cleaned up before the merged-user deletion event is processed. +func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, error) { + cacheKey := kvKeyPrimaryEmailPrefix + userSfid + if raw, err := readMappingsKVValueFn(ctx, cacheKey); err == nil { + if email := strings.TrimSpace(string(raw)); email != "" { + return email, nil + } + } + return lookupPrimaryEmailForUserFn(ctx, userSfid) +} + +// clearPrimaryEmailCacheIfMatched deletes the cached primary email when it still +// points at emailAddr (e.g. after a primary row is demoted or deleted). +func clearPrimaryEmailCacheIfMatched(ctx context.Context, key, userSfid, emailAddr string) { + if emailAddr == "" || userSfid == "" { + return + } + cacheKey := kvKeyPrimaryEmailPrefix + userSfid + raw, err := readMappingsKVValueFn(ctx, cacheKey) + if err != nil { + return + } + if !strings.EqualFold(strings.TrimSpace(string(raw)), strings.TrimSpace(emailAddr)) { + return + } + if err := deleteIndexKeyFn(ctx, cacheKey); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete stale primary email cache") + } +} + +// userDeletedEvent is the payload published to "lfx.v1-sync-helper.user.deleted" when a +// merged user is soft-deleted. Username is the normalized LFID; Email is the deleted +// account's primary email when available so downstream scrubbers can distinguish LFID reuse. +type userDeletedEvent struct { + Username string `json:"username"` + Email string `json:"email,omitempty"` +} + +const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted" + +// publishUserDeletedEvent publishes a user-deleted NATS event. Best-effort: publish +// errors are logged and do not affect the delete handler's return value (the JetStream +// KV delete is already ACKed). A failed publish can leave username PII in v2 settings +// until a manual re-sync; scrub subscribers treat the event as idempotent. +var natsPublishBytesFn = func(subject string, data []byte) error { + return natsConn.Publish(subject, data) +} + +func publishUserDeletedEvent(ctx context.Context, key, username, email string) { + payload, err := json.Marshal(userDeletedEvent{Username: username, Email: email}) + if err != nil { + logger.With(errKey, err, "key", key). + ErrorContext(ctx, "failed to marshal user-deleted event; committee username scrub skipped") + return + } + if err := natsPublishBytesFn(v1SyncHelperUserDeletedSubject, payload); err != nil { + logger.With(errKey, err, "key", key). + ErrorContext(ctx, "failed to publish user-deleted event; committee username scrub skipped") + return + } + logger.With("key", key). + InfoContext(ctx, "published user-deleted event for committee username scrub") +} + // syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns // true if the error is retryable so the caller can NACK the JetStream message. func syncMergedUserProfile(ctx context.Context, key, auth0UserID string, v1Data map[string]any) bool { @@ -225,12 +343,23 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri } } - // Skip primary emails — the primary email is the Auth0 user's own email - // field, not a linked identity, so it is out of scope for this handler. + // Primary emails are not linked as Auth0 identities (they are the Auth0 user's own email). + // Cache the address in mappings so handleMergedUserDelete can supply it to downstream + // scrubbers even after the alternate-email rows have been cleaned up ahead of the + // merged-user row. if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { + if emailAddr != "" { + cacheKey := kvKeyPrimaryEmailPrefix + leadorcontactid + if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid). + WarnContext(ctx, "failed to cache primary email for user deletion scrub") + } + } return false } + clearPrimaryEmailCacheIfMatched(ctx, key, leadorcontactid, emailAddr) + // If this is the user's only qualifying alternate email, treat it as // though it were flagged primary (see isSoleQualifyingAlternateEmail): // v1 lazy-sync may not have created/synced the primary row yet. @@ -340,6 +469,7 @@ func handleAlternateEmailDelete(ctx context.Context, key, emailSfid string, v1Da // Skip primary emails — the primary email is the Auth0 user's own email // field, not a linked identity, so it is out of scope for this handler. if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { + clearPrimaryEmailCacheIfMatched(ctx, key, userSfid, emailAddr) return false } diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 99ba06f..f363763 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -5,6 +5,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -320,6 +321,349 @@ func TestExtractUsernameIndex(t *testing.T) { } } +// TestHandleMergedUserDeleteScrub verifies that handleMergedUserDelete triggers the +// committee username scrub (NATS publish) when a username is present in the payload. +func TestHandleMergedUserDeleteScrub(t *testing.T) { + origLogger := logger + origDeleteIndex := deleteIndexKeyFn + origPublish := publishUserDeletedEventFn + origEmail := getPrimaryEmailForUserFn + t.Cleanup(func() { + logger = origLogger + deleteIndexKeyFn = origDeleteIndex + publishUserDeletedEventFn = origPublish + getPrimaryEmailForUserFn = origEmail + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + const ( + userSfid = "003ABC" + username = "alice" + ) + + tests := []struct { + name string + v1Data map[string]any + emailResult string + emailErr error + wantPublished bool + wantUsername string + wantEmail string + wantDeleted []string + }{ + { + name: "username present → publish normalized event and clear primary-email cache", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": " Alice ", + }, + emailResult: "deleted@example.com", + wantPublished: true, + wantUsername: "alice", + wantEmail: "deleted@example.com", + wantDeleted: []string{ + kvKeyUsernamePrefix + usernameToKVKey(" Alice "), + kvKeyPrimaryEmailPrefix + userSfid, + }, + }, + { + name: "email lookup error → still publish without email", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": username, + }, + emailErr: errors.New("alternate emails mapping gone"), + wantPublished: true, + wantUsername: username, + wantEmail: "", + wantDeleted: []string{ + kvKeyUsernamePrefix + usernameToKVKey(username), + kvKeyPrimaryEmailPrefix + userSfid, + }, + }, + { + name: "whitespace-only username → no publish but clear primary-email cache", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": " ", + }, + wantPublished: false, + wantDeleted: []string{ + kvKeyPrimaryEmailPrefix + userSfid, + }, + }, + { + name: "no username → no publish but still clear primary-email cache", + v1Data: map[string]any{ + "sfid": userSfid, + }, + wantPublished: false, + wantDeleted: []string{ + kvKeyPrimaryEmailPrefix + userSfid, + }, + }, + { + name: "nil v1Data (hard KV delete) → no publish but clear primary-email cache", + v1Data: nil, + wantPublished: false, + wantDeleted: []string{ + kvKeyPrimaryEmailPrefix + userSfid, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var deletedKeys []string + deleteIndexKeyFn = func(_ context.Context, key string) error { + deletedKeys = append(deletedKeys, key) + return nil + } + getPrimaryEmailForUserFn = func(_ context.Context, gotSfid string) (string, error) { + if gotSfid != userSfid { + t.Errorf("getPrimaryEmailForUserFn called with sfid %q, want %q", gotSfid, userSfid) + } + return tc.emailResult, tc.emailErr + } + + var publishedUsername string + var publishedEmail string + var publishCalled bool + + publishUserDeletedEventFn = func(_ context.Context, _, u, e string) { + publishCalled = true + publishedUsername = u + publishedEmail = e + } + + got := handleMergedUserDelete(context.Background(), "test-key", userSfid, tc.v1Data) + + if got { + t.Errorf("handleMergedUserDelete() = true, want false") + } + if publishCalled != tc.wantPublished { + t.Errorf("publishCalled = %v, want %v", publishCalled, tc.wantPublished) + } + if tc.wantPublished && publishedUsername != tc.wantUsername { + t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername) + } + if tc.wantPublished && publishedEmail != tc.wantEmail { + t.Errorf("published email = %q, want %q", publishedEmail, tc.wantEmail) + } + if tc.wantDeleted != nil { + if len(deletedKeys) != len(tc.wantDeleted) { + t.Fatalf("deletedKeys = %v, want %v", deletedKeys, tc.wantDeleted) + } + for i, want := range tc.wantDeleted { + if deletedKeys[i] != want { + t.Errorf("deletedKeys[%d] = %q, want %q", i, deletedKeys[i], want) + } + } + } else if len(deletedKeys) != 0 { + t.Errorf("expected no index deletes, got %v", deletedKeys) + } + }) + } +} + +// TestGetCachedPrimaryEmailForUser verifies cache-first lookup with live fallback. +func TestGetCachedPrimaryEmailForUser(t *testing.T) { + origRead := readMappingsKVValueFn + origLive := lookupPrimaryEmailForUserFn + t.Cleanup(func() { + readMappingsKVValueFn = origRead + lookupPrimaryEmailForUserFn = origLive + }) + + const userSfid = "003ABC" + + t.Run("cache hit → no live lookup", func(t *testing.T) { + readMappingsKVValueFn = func(_ context.Context, key string) ([]byte, error) { + if key == kvKeyPrimaryEmailPrefix+userSfid { + return []byte("cached@example.com"), nil + } + return nil, errors.New("unexpected key") + } + lookupPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) { + t.Fatal("live lookup should not run on cache hit") + return "", nil + } + + email, err := getCachedPrimaryEmailForUser(context.Background(), userSfid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if email != "cached@example.com" { + t.Fatalf("email = %q, want cached@example.com", email) + } + }) + + t.Run("cache miss → live lookup", func(t *testing.T) { + readMappingsKVValueFn = func(_ context.Context, _ string) ([]byte, error) { + return nil, errors.New("cache miss") + } + lookupPrimaryEmailForUserFn = func(_ context.Context, gotSfid string) (string, error) { + if gotSfid != userSfid { + t.Errorf("lookupPrimaryEmailForUserFn sfid = %q, want %q", gotSfid, userSfid) + } + return "live@example.com", nil + } + + email, err := getCachedPrimaryEmailForUser(context.Background(), userSfid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if email != "live@example.com" { + t.Fatalf("email = %q, want live@example.com", email) + } + }) +} + +func TestPublishUserDeletedEvent(t *testing.T) { + origLogger := logger + origPublish := natsPublishBytesFn + t.Cleanup(func() { + logger = origLogger + natsPublishBytesFn = origPublish + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + t.Run("publishes normalized payload on subject", func(t *testing.T) { + var gotSubject string + var gotPayload userDeletedEvent + natsPublishBytesFn = func(subject string, data []byte) error { + gotSubject = subject + if err := json.Unmarshal(data, &gotPayload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return nil + } + + publishUserDeletedEvent(context.Background(), "test-key", "alice", "alice@example.com") + + if gotSubject != v1SyncHelperUserDeletedSubject { + t.Fatalf("subject = %q, want %q", gotSubject, v1SyncHelperUserDeletedSubject) + } + if gotPayload.Username != "alice" { + t.Fatalf("username = %q, want alice", gotPayload.Username) + } + if gotPayload.Email != "alice@example.com" { + t.Fatalf("email = %q, want alice@example.com", gotPayload.Email) + } + }) + + t.Run("publish error is swallowed", func(_ *testing.T) { + natsPublishBytesFn = func(_ string, _ []byte) error { + return errors.New("nats unavailable") + } + publishUserDeletedEvent(context.Background(), "test-key", "alice", "") + }) +} + +// TestClearPrimaryEmailCacheIfMatched verifies conditional cache cleanup on demote/delete paths. +func TestClearPrimaryEmailCacheIfMatched(t *testing.T) { + origLogger := logger + origRead := readMappingsKVValueFn + origDelete := deleteIndexKeyFn + t.Cleanup(func() { + logger = origLogger + readMappingsKVValueFn = origRead + deleteIndexKeyFn = origDelete + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + const ( + key = "test-key" + userSfid = "003ABC" + ) + cacheKey := kvKeyPrimaryEmailPrefix + userSfid + + tests := []struct { + name string + sfid string + emailAddr string + readResult []byte + readErr error + wantDelete bool + }{ + { + name: "match → delete", + sfid: userSfid, + emailAddr: "a@example.com", + readResult: []byte("a@example.com"), + wantDelete: true, + }, + { + name: "case-insensitive match → delete", + sfid: userSfid, + emailAddr: "A@Example.COM", + readResult: []byte("a@example.com"), + wantDelete: true, + }, + { + name: "no match → skip", + sfid: userSfid, + emailAddr: "a@example.com", + readResult: []byte("b@example.com"), + wantDelete: false, + }, + { + name: "empty email → skip", + sfid: userSfid, + emailAddr: "", + readResult: []byte("a@example.com"), + wantDelete: false, + }, + { + name: "empty user sfid → skip", + sfid: "", + emailAddr: "a@example.com", + readResult: []byte("a@example.com"), + wantDelete: false, + }, + { + name: "read error → skip", + sfid: userSfid, + emailAddr: "a@example.com", + readErr: errors.New("cache miss"), + wantDelete: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readMappingsKVValueFn = func(_ context.Context, gotKey string) ([]byte, error) { + if tt.sfid == "" { + t.Fatal("read should not run when user sfid is empty") + } + if gotKey != cacheKey { + t.Fatalf("read key = %q, want %q", gotKey, cacheKey) + } + return tt.readResult, tt.readErr + } + + var deletedKeys []string + deleteIndexKeyFn = func(_ context.Context, gotKey string) error { + deletedKeys = append(deletedKeys, gotKey) + return nil + } + + clearPrimaryEmailCacheIfMatched(context.Background(), key, tt.sfid, tt.emailAddr) + + if tt.wantDelete { + if len(deletedKeys) != 1 { + t.Fatalf("deletedKeys = %v, want one delete", deletedKeys) + } + if deletedKeys[0] != cacheKey { + t.Fatalf("deleted key = %q, want %q", deletedKeys[0], cacheKey) + } + } else if len(deletedKeys) != 0 { + t.Fatalf("expected no delete, got %v", deletedKeys) + } + }) + } +} + // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct { @@ -376,12 +720,14 @@ func TestHandleAlternateEmailDelete(t *testing.T) { origUnlink := unlinkEmailIdentityFn origUpdateEmails := updateContactEmailMappingIndexFn origDeleteIndex := deleteIndexKeyFn + origReadCache := readMappingsKVValueFn t.Cleanup(func() { logger = origLogger lookupMergedUserFn = origLookup unlinkEmailIdentityFn = origUnlink updateContactEmailMappingIndexFn = origUpdateEmails deleteIndexKeyFn = origDeleteIndex + readMappingsKVValueFn = origReadCache }) logger = slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -390,6 +736,9 @@ func TestHandleAlternateEmailDelete(t *testing.T) { // so return nil to simulate the normal successful case. updateContactEmailMappingIndexFn = func(_ context.Context, _, _ string, _ bool) error { return nil } deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } + readMappingsKVValueFn = func(_ context.Context, _ string) ([]byte, error) { + return nil, errors.New("cache miss") + } const ( userSfid = "003DEF" diff --git a/go.mod b/go.mod index 248f3d7..6836383 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 goa.design/goa/v3 v3.26.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.39.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 7aeb9ca..cca2b00 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pyproject.toml b/pyproject.toml index 5110d0e..a054e2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,6 @@ dependencies = [ [tool.uv] constraint-dependencies = [ - "pip>=26.1", - "uv>=0.11.6", + "pip>=26.1.2", + "uv>=0.11.15", ] diff --git a/uv.lock b/uv.lock index d413004..0b06ad1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,11 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" [manifest] constraints = [ - { name = "pip", specifier = ">=26.1" }, - { name = "uv", specifier = ">=0.11.6" }, + { name = "pip", specifier = ">=26.1.2" }, + { name = "uv", specifier = ">=0.11.15" }, ] [[package]] @@ -190,7 +190,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -370,11 +372,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.1" +version = "26.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, ] [[package]] @@ -686,28 +688,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/a3/be4a946c7c2fc4094c020c8f7d8bd0a739bad55ebe4e2817d6e2b1bc6bff/uv-0.11.14.tar.gz", hash = "sha256:0ea006a117b586b2681b6dfd9703a540d2ad2a136ec0f48d272767e599cc3dfb", size = 4130699, upload-time = "2026-05-12T18:00:37.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/15/9b2138b16eb1fa8c2cd84b1037ad10c38b3acc36ce96c6d27000bfb7e716/uv-0.11.14-py3-none-linux_armv6l.whl", hash = "sha256:78411a883f230a710af19f2ac6e6f0ba8eae90f0e5af4605f923fd367539fff4", size = 23545199, upload-time = "2026-05-12T18:01:34.526Z" }, - { url = "https://files.pythonhosted.org/packages/75/81/c678e8b9a8e624f9c338c66cd57dd9cfc6b5a0501ad3c87fd0cc0bf8850a/uv-0.11.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:078f2e63da89c8fcf6d578f02156045c5990c57d76464aab3f3f798d3fff95cd", size = 22957064, upload-time = "2026-05-12T18:00:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ad/95fbd15b23f26f36d0cfb0ddf159b9602a1b1c0feced60a7f98385e919f1/uv-0.11.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdad43d52c130e3159e84ab1844e04d819d2c4a2495a687d27f80d560a3650e", size = 21678307, upload-time = "2026-05-12T18:00:57.132Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cb/b3da1c4d95d6dd507896bca16dbd643118013b2b151f5f35a08d3391728c/uv-0.11.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:9923da7c63d70de9fe71829503d7e7ebfd6304e804d7232aad5f716e190db25b", size = 23353409, upload-time = "2026-05-12T18:01:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/78c6b8d6bcc04c5043b50631e9b413422a03a0bd7c4a997748f8e9cbac25/uv-0.11.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3b0759ca504e48dcd4fafb1a61ef69aeb24c5a60fbf5f504a7873c8db1b24718", size = 23103964, upload-time = "2026-05-12T18:01:31.094Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/acb66e09bc54a74e4288e996d841af04d88588fd6bdbfbab2468ab7169a7/uv-0.11.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78b51b117549ee4db7197ea5ece0848cecd443e464fb9dff9f254cdc1e4ed96f", size = 23104638, upload-time = "2026-05-12T18:01:10.093Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/8497be61accdb8e56d02e11edd3ac471466259420e0bd9c05c1966df134a/uv-0.11.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1ddbe8a2ab160affc179e9c3a40913b23a08cdf55254e1f3829cc22a51a0d8d", size = 24625888, upload-time = "2026-05-12T18:01:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/95/91/f730799fd20a45777b255e20cf9f648a4e4e0979bf65e87a8633197cf7d9/uv-0.11.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3005a2db1e8d72e125630d4f22ac4ceddb2c033e1f9b94b7f3ea38ebac46dd6", size = 25445231, upload-time = "2026-05-12T18:00:40.012Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4d/106463fc27e63e402aec2e791774dac2db5bd5e1c36cdcf38125aa97ab1c/uv-0.11.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5c8f9ea36274ef2f9d24f0522085e280844172e901d9213f66a21b212266706", size = 24571961, upload-time = "2026-05-12T18:00:43.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/4d/163fe746b97bd1129627e8b1f943e17583ddc143eaab532d56a799a9ba5a/uv-0.11.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:379e64b236cf55f762a8308d7efe4365d5296ba29f3a4868761bc45b4e915a71", size = 24718523, upload-time = "2026-05-12T18:01:06.587Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/7a3673494a0cf70267559166398f9c50c4925ff20122f99a28d6c5a80d83/uv-0.11.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:29c12a562441fc2d604e6920c558cacce74a55f889468708683a79b35a6e18a1", size = 23454821, upload-time = "2026-05-12T18:00:51.166Z" }, - { url = "https://files.pythonhosted.org/packages/bb/43/6358394a567d865f3a5ce27b1e0d939549911e36d9b59f0c545a167f92f7/uv-0.11.14-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:e84069681c0334e07cbc7f114eb09d7fe1335e1db0297a66dbca80a1b393fe6d", size = 24087843, upload-time = "2026-05-12T18:00:47.272Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f6/7d0ae1e1f52b85057ca24d8876d6a4cc87b541ea6aca627fe36594c06099/uv-0.11.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b15bf7c146e38d7c938d3a207115d5fdd8ef764fe1f866c225b1bed27e88da1e", size = 24147611, upload-time = "2026-05-12T18:01:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a2/511ad0c5da5697fd990b99569425b62b81cbc3458c35acc845211b55d6b5/uv-0.11.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ddda5c5e41097814adac535c74851bae55e8097b9afc79aeae7fcffd8d86c06d", size = 23920348, upload-time = "2026-05-12T18:01:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b6/7084e3401b1f1020f215a125136eec1ed2bd541e10a5fea1625515579599/uv-0.11.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e54326703f1eca83a6fd73275e0f398b16b7d3f81531bf58899c2869bc403f6c", size = 24928981, upload-time = "2026-05-12T18:01:13.961Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6a/7e81729fe729889c8cc63bbf64291734359bd7f6ba84852dc0504453511d/uv-0.11.14-py3-none-win32.whl", hash = "sha256:b384d873d0d18552c7524226125efd3965d921b7134c2f476c333771beb733e1", size = 22573503, upload-time = "2026-05-12T18:00:34.36Z" }, - { url = "https://files.pythonhosted.org/packages/94/5d/f8905f9af5cd46af2a688b2246dbb5a4d95b8557eeffd7f241e037659d9e/uv-0.11.14-py3-none-win_amd64.whl", hash = "sha256:f0a8b58b38e984241bca5d7a5a47bf9ffe1ca2ab392a640887db8a04c4a9ec95", size = 25175590, upload-time = "2026-05-12T18:01:00.38Z" }, - { url = "https://files.pythonhosted.org/packages/04/cb/7333d08d944f3018eb89242cd5e646e7b37faa1b567faeaf9254a8b59d53/uv-0.11.14-py3-none-win_arm64.whl", hash = "sha256:6a13e7e064563050c6606b3fd77091d427cdbdc5938b6f134baf8d8ec79bfdb7", size = 23594775, upload-time = "2026-05-12T18:01:03.55Z" }, +version = "0.11.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/7c/29fad91a39d7926681f20245d60815b14b481f300cef9c37f1e5ae3cd2a7/uv-0.11.32.tar.gz", hash = "sha256:5359a7b0de78ba99b2519d33c173e004c39111c4baebe1b7c3d111a2de2011e1", size = 5790510, upload-time = "2026-07-23T23:05:52.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/ca/5ea42012c0ecbaac481f099f3c90ee4db737b3ec0bb6f51fa571e234e740/uv-0.11.32-py3-none-linux_armv6l.whl", hash = "sha256:26d61d1b640d2dcfd7c3b64e75ada14c8eb477accba57f414c1a3759d6ab2253", size = 25933056, upload-time = "2026-07-23T23:05:06.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/85/ef4d0f02f469cfad1d73c11bf31ac0a8f24da3c62d7f3305c50a5e51e814/uv-0.11.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c82954b1bd507e767b1d996c2865ea3eb7d464a479ec0e4d60f0b8e2e07dee45", size = 24918625, upload-time = "2026-07-23T23:05:09.299Z" }, + { url = "https://files.pythonhosted.org/packages/60/43/ec2de11ef008e76a8d01c34d573c939c565694f72c0d008d200aa3a5eac3/uv-0.11.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a56d1abf16337350e878b2c869b256b8448ecc4a722b98f8d41e1b3680a587b", size = 23519325, upload-time = "2026-07-23T23:05:11.877Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/0d6a8ee5066385fce65a5ef57939b1f63e9ff955afa07fa423c3e7e0abdb/uv-0.11.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:125c142363d0842c8506a057da56bae182e2aa3957344f57dd9ef20ea10f06b0", size = 25450318, upload-time = "2026-07-23T23:05:14.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/ec/fee4f0d12c7c42bf3b86eda615185860a76505c19e5b0e82afee8bf1c1a3/uv-0.11.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:96625d832fe5b94ed0a029764cef0879ff47e3a525aec5c99d360a959e2f4aea", size = 25421693, upload-time = "2026-07-23T23:05:17.083Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/cd77d7d87411030fb183f0e444634d7ba9803cd6ab64bc240d73ce50a071/uv-0.11.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ffc8322ce88b29dc0a905c102ea7d32c9021ee0353b641674e350c65fa930a", size = 25451542, upload-time = "2026-07-23T23:05:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/78/3b/3127e224c05101a9675b579978c02d3b500f4362004abb177acdc6294264/uv-0.11.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e36fe8ff79ab7d13e9ad45d24bb9a52c1024caa6897cf62933bb3f760643d13", size = 26809886, upload-time = "2026-07-23T23:05:22.35Z" }, + { url = "https://files.pythonhosted.org/packages/4e/59/82c05d4f1ce1de47667efa6548a14240a9c409388a09b788386317e190ca/uv-0.11.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:19fb9f2dba53851c0ee6e7e5582bd355fcaa9b1387b5d688f5bb9efc543dc605", size = 27608113, upload-time = "2026-07-23T23:05:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/649b350677a1fb07938e1b5b33ac7564339f5e1f295a4a958b7181eb9924/uv-0.11.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f030a91a94655af11e41dafe1b41e1a90e1f2641bf13ff7ed7d4f5fb4f031d", size = 26788162, upload-time = "2026-07-23T23:05:27.585Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/114463d056b6b328d45557001e848b8ab15539bd8f4fa7a457ccb83e2b5d/uv-0.11.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3da76cd4e2697de30928b8a8524bd39183ac1e08cb7e72833807c022b7cba6c4", size = 27013687, upload-time = "2026-07-23T23:05:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/6ee5278036c57bcc7ee9c99cd890bac20d4431e63416ffe4080ba8f92767/uv-0.11.32-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be0799f1ad70c755d10de5aaf46af94199d4f16a992f90278f2662350cd3f4fe", size = 25582781, upload-time = "2026-07-23T23:05:32.623Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/2055d3db9ee3e4f59a14912bfcfacb3ec537d60d8b51a8477e4ddfc11e5c/uv-0.11.32-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f35b3b8b65ba4579f8b23645a19394f7e5f90b20b07376ecff18b3bb656a81ae", size = 26291488, upload-time = "2026-07-23T23:05:35.219Z" }, + { url = "https://files.pythonhosted.org/packages/dc/59/999a5e83f927539a5b90b708abf77642f07faccf04f37c1f933864c403c6/uv-0.11.32-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cd085addb35f074561d2c5659a088f5f10361502bd3ad4c8f5105f5a4b9d2817", size = 26419928, upload-time = "2026-07-23T23:05:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/7575563aeac458053d220a95eb1c5aeda14870414bd6db00e9eb673c3793/uv-0.11.32-py3-none-musllinux_1_1_i686.whl", hash = "sha256:43360834111ae917808a70b36f4fb0e53de8e0be422e4cd4445876ffae8decc2", size = 26077451, upload-time = "2026-07-23T23:05:40.286Z" }, + { url = "https://files.pythonhosted.org/packages/02/58/0a92505cfe2b02b1c2d6ded23e83763fe98b43fbe1a4e5cbd6866da3bdf7/uv-0.11.32-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:77f4356548ee8dc47efae154efd4e930c65570e7d4971c57bdef592f6eefb39c", size = 27223406, upload-time = "2026-07-23T23:05:43.037Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/1f7cf58780bb1c5d6ea89126438971d8e61ad04d290e1c34ce1e8b136a83/uv-0.11.32-py3-none-win32.whl", hash = "sha256:bb1ae8189e315499a77f3cc27cac5985ab1d8cb79baa82e368b6c1e574a7d9cf", size = 24681569, upload-time = "2026-07-23T23:05:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/57/60/a645cca710448004268b7731c4dc6fc7d084a7c620fc73ecc2127cb61661/uv-0.11.32-py3-none-win_amd64.whl", hash = "sha256:855632ee1d2a8491986efa54c562b806cd32477761889a1e13624b519d98a45e", size = 27780686, upload-time = "2026-07-23T23:05:48.141Z" }, + { url = "https://files.pythonhosted.org/packages/13/68/efb90a3e46c9f93e577b34e82467f232308a0ff3b12f807226caca6f72e2/uv-0.11.32-py3-none-win_arm64.whl", hash = "sha256:bf988bf510772785eac1edfc7cdedca074e8936b45e755b58f7f3e1e9ca86424", size = 25882589, upload-time = "2026-07-23T23:05:50.723Z" }, ] [[package]]