identity-plan-kit 0.2.6__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/PKG-INFO +1 -1
  2. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/pyproject.toml +2 -2
  3. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/dependencies.py +84 -1
  4. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/repositories/user_repo.py +14 -6
  5. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/services/auth_service.py +9 -2
  6. identity_plan_kit-0.2.7/src/identity_plan_kit/plans/cache/user_plan_cache.py +205 -0
  7. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/services/plan_service.py +108 -13
  8. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/uv.lock +1 -1
  9. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/.gitignore +0 -0
  10. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/Makefile +0 -0
  11. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/README.md +0 -0
  12. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/alembic/env.py +0 -0
  13. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/alembic/script.py.mako +0 -0
  14. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/alembic/versions/20250124_000000_initial_schema.py +0 -0
  15. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/alembic/versions/20250127_000000_add_password_hash.py +0 -0
  16. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/alembic.ini +0 -0
  17. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/integration/__init__.py +0 -0
  18. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/integration/fastapi_integration.py +0 -0
  19. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/integration/sample_alembic_env.py +0 -0
  20. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/integration/webhook_integration.py +0 -0
  21. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/playing/admin_usage.py +0 -0
  22. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/playing/basic_usage.py +0 -0
  23. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/playing/custom_error_formats.py +0 -0
  24. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/playing/extending_models.html +0 -0
  25. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/playing/extension_example.py +0 -0
  26. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/prod/.env.production.example +0 -0
  27. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/prod/Dockerfile +0 -0
  28. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/prod/docker-compose.yml +0 -0
  29. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/prod/nginx.conf +0 -0
  30. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/prod/production_setup.py +0 -0
  31. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/examples/prod/prometheus.yml +0 -0
  32. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/README.md +0 -0
  33. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/__init__.py +0 -0
  34. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/config.py +0 -0
  35. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/locustfile.py +0 -0
  36. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/test_auth.py +0 -0
  37. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/test_database_stress.py +0 -0
  38. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/test_mixed_scenarios.py +0 -0
  39. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/test_plans_quota.py +0 -0
  40. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/test_quota_direct.py +0 -0
  41. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/test_rbac_cache.py +0 -0
  42. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/loadtests/utils.py +0 -0
  43. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/__init__.py +0 -0
  44. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/admin/__init__.py +0 -0
  45. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/admin/auth.py +0 -0
  46. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/admin/views.py +0 -0
  47. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/__init__.py +0 -0
  48. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/domain/__init__.py +0 -0
  49. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/domain/entities.py +0 -0
  50. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/domain/exceptions.py +0 -0
  51. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/dto/__init__.py +0 -0
  52. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/dto/requests.py +0 -0
  53. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/dto/responses.py +0 -0
  54. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/handlers/__init__.py +0 -0
  55. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/handlers/oauth_routes.py +0 -0
  56. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/models/__init__.py +0 -0
  57. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/models/refresh_token.py +0 -0
  58. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/models/user.py +0 -0
  59. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/models/user_provider.py +0 -0
  60. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/repositories/__init__.py +0 -0
  61. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/repositories/token_repo.py +0 -0
  62. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/services/__init__.py +0 -0
  63. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/services/oauth_service.py +0 -0
  64. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/auth/uow.py +0 -0
  65. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/cli.py +0 -0
  66. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/config.py +0 -0
  67. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/kit.py +0 -0
  68. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/migrations.py +0 -0
  69. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/__init__.py +0 -0
  70. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/cache/__init__.py +0 -0
  71. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/cache/plan_cache.py +0 -0
  72. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/dependencies.py +0 -0
  73. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/domain/__init__.py +0 -0
  74. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/domain/entities.py +0 -0
  75. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/domain/exceptions.py +0 -0
  76. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/dto/__init__.py +0 -0
  77. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/dto/responses.py +0 -0
  78. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/dto/usage.py +0 -0
  79. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/handlers/__init__.py +0 -0
  80. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/handlers/plan_routes.py +0 -0
  81. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/__init__.py +0 -0
  82. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/feature.py +0 -0
  83. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/feature_usage.py +0 -0
  84. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/plan.py +0 -0
  85. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/plan_limit.py +0 -0
  86. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/plan_permission.py +0 -0
  87. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/models/user_plan.py +0 -0
  88. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/repositories/__init__.py +0 -0
  89. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/repositories/plan_repo.py +0 -0
  90. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/repositories/usage_repo.py +0 -0
  91. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/services/__init__.py +0 -0
  92. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/plans/uow.py +0 -0
  93. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/__init__.py +0 -0
  94. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/cache/__init__.py +0 -0
  95. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/cache/permission_cache.py +0 -0
  96. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/dependencies.py +0 -0
  97. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/domain/__init__.py +0 -0
  98. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/domain/entities.py +0 -0
  99. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/domain/exceptions.py +0 -0
  100. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/models/__init__.py +0 -0
  101. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/models/permission.py +0 -0
  102. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/models/role.py +0 -0
  103. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/models/role_permission.py +0 -0
  104. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/repositories/__init__.py +0 -0
  105. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/repositories/rbac_repo.py +0 -0
  106. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/services/__init__.py +0 -0
  107. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/services/rbac_service.py +0 -0
  108. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/rbac/uow.py +0 -0
  109. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/__init__.py +0 -0
  110. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/audit.py +0 -0
  111. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/circuit_breaker.py +0 -0
  112. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/cleanup_scheduler.py +0 -0
  113. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/database.py +0 -0
  114. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/error_formatter.py +0 -0
  115. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/exception_handlers.py +0 -0
  116. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/exceptions.py +0 -0
  117. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/graceful_shutdown.py +0 -0
  118. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/health.py +0 -0
  119. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/http_utils.py +0 -0
  120. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/lockout.py +0 -0
  121. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/logging.py +0 -0
  122. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/metrics.py +0 -0
  123. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/models.py +0 -0
  124. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/rate_limiter.py +0 -0
  125. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/request_id.py +0 -0
  126. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/schemas.py +0 -0
  127. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/security.py +0 -0
  128. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/state_store.py +0 -0
  129. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/uow.py +0 -0
  130. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/src/identity_plan_kit/shared/uuid7.py +0 -0
  131. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/__init__.py +0 -0
  132. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/__init__.py +0 -0
  133. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_auth_service.py +0 -0
  134. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_circuit_breaker.py +0 -0
  135. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_csrf_state.py +0 -0
  136. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_dependencies.py +0 -0
  137. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_domain_entities.py +0 -0
  138. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_http_utils.py +0 -0
  139. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_lockout.py +0 -0
  140. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_lockout_concurrent.py +0 -0
  141. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_oauth_service_resilience.py +0 -0
  142. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_security_comprehensive.py +0 -0
  143. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/auth/test_token_security.py +0 -0
  144. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/conftest.py +0 -0
  145. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/__init__.py +0 -0
  146. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/conftest.py +0 -0
  147. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_concurrent_limit_merge.py +0 -0
  148. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_concurrent_plan_operations.py +0 -0
  149. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_concurrent_token_operations.py +0 -0
  150. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_concurrent_user_creation.py +0 -0
  151. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_concurrent_user_deactivation.py +0 -0
  152. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_limit_boundaries.py +0 -0
  153. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_plan_cache_race.py +0 -0
  154. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_quota_concurrency.py +0 -0
  155. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_token_cleanup.py +0 -0
  156. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_token_repository.py +0 -0
  157. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_usage_periods.py +0 -0
  158. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/integration/test_user_repository.py +0 -0
  159. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/plans/__init__.py +0 -0
  160. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/plans/test_domain_entities.py +0 -0
  161. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/plans/test_period_edge_cases.py +0 -0
  162. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/plans/test_plan_cache_concurrent.py +0 -0
  163. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/plans/test_plan_service.py +0 -0
  164. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/rbac/__init__.py +0 -0
  165. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/rbac/test_permission_cache.py +0 -0
  166. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/rbac/test_permission_cache_concurrent.py +0 -0
  167. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/rbac/test_rbac_cache_stampede.py +0 -0
  168. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/rbac/test_rbac_service.py +0 -0
  169. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_audit_pii_masking.py +0 -0
  170. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_circuit_breaker_concurrent.py +0 -0
  171. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_cleanup_scheduler_concurrent.py +0 -0
  172. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_config_validation.py +0 -0
  173. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_http_utils_security.py +0 -0
  174. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_rate_limiter.py +0 -0
  175. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_rate_limiter_concurrent.py +0 -0
  176. {identity_plan_kit-0.2.6 → identity_plan_kit-0.2.7}/tests/shared/test_state_store_concurrent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: identity-plan-kit
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Modern FastAPI library for authentication, RBAC, subscription plans, and usage tracking
5
5
  Author-email: harut <harut.avetisyan2002@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "identity-plan-kit"
3
- version = "0.2.6"
3
+ version = "0.2.7"
4
4
  description = "Modern FastAPI library for authentication, RBAC, subscription plans, and usage tracking"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -140,7 +140,7 @@ testpaths = ["tests"]
140
140
  addopts = "-v --tb=short"
141
141
 
142
142
  [tool.bumpversion]
143
- current_version = "0.2.6"
143
+ current_version = "0.2.7"
144
144
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
145
145
  serialize = ["{major}.{minor}.{patch}"]
146
146
  tag = true
@@ -1,6 +1,7 @@
1
1
  """Auth FastAPI dependencies."""
2
2
 
3
- from typing import Annotated
3
+ from collections.abc import Callable, Coroutine
4
+ from typing import Annotated, Any
4
5
 
5
6
  from fastapi import Cookie, Depends, HTTPException, Request, status
6
7
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -21,6 +22,84 @@ logger = get_logger(__name__)
21
22
  bearer_scheme = HTTPBearer(auto_error=False)
22
23
 
23
24
 
25
+ def current_user(
26
+ include_role: bool = True,
27
+ ) -> Callable[..., Coroutine[Any, Any, User]]:
28
+ """
29
+ Create a parameterized current user dependency.
30
+
31
+ Use this when you need to control whether role is loaded:
32
+
33
+ Example::
34
+
35
+ # Skip role loading for better performance
36
+ @router.get("/generate")
37
+ async def generate(
38
+ user: Annotated[User, Depends(current_user(include_role=False))],
39
+ ):
40
+ ...
41
+
42
+ Args:
43
+ include_role: If True, eagerly load the user's role (default: True).
44
+ Set to False to skip the role query when role info is not needed.
45
+
46
+ Returns:
47
+ FastAPI dependency function
48
+ """
49
+
50
+ async def _get_user(
51
+ request: Request,
52
+ credentials: Annotated[
53
+ HTTPAuthorizationCredentials | None,
54
+ Depends(bearer_scheme),
55
+ ] = None,
56
+ access_token: Annotated[str | None, Cookie(alias="access_token")] = None,
57
+ ) -> User:
58
+ token: str | None = None
59
+ if credentials:
60
+ token = credentials.credentials
61
+ elif access_token:
62
+ token = access_token
63
+
64
+ if not token:
65
+ raise HTTPException(
66
+ status_code=status.HTTP_401_UNAUTHORIZED,
67
+ detail="Not authenticated",
68
+ headers={"WWW-Authenticate": "Bearer"},
69
+ )
70
+
71
+ kit = request.app.state.identity_plan_kit
72
+ auth_service = kit.auth_service
73
+
74
+ try:
75
+ return await auth_service.get_user_from_token(token, include_role=include_role)
76
+ except TokenExpiredError:
77
+ raise HTTPException(
78
+ status_code=status.HTTP_401_UNAUTHORIZED,
79
+ detail="Token has expired",
80
+ headers={"WWW-Authenticate": "Bearer"},
81
+ ) from None
82
+ except (TokenInvalidError, AuthError):
83
+ raise HTTPException(
84
+ status_code=status.HTTP_401_UNAUTHORIZED,
85
+ detail="Invalid token",
86
+ headers={"WWW-Authenticate": "Bearer"},
87
+ ) from None
88
+ except UserNotFoundError:
89
+ raise HTTPException(
90
+ status_code=status.HTTP_401_UNAUTHORIZED,
91
+ detail="User not found",
92
+ headers={"WWW-Authenticate": "Bearer"},
93
+ ) from None
94
+ except UserInactiveError:
95
+ raise HTTPException(
96
+ status_code=status.HTTP_403_FORBIDDEN,
97
+ detail="User account is inactive",
98
+ ) from None
99
+
100
+ return _get_user
101
+
102
+
24
103
  async def get_current_user(
25
104
  request: Request,
26
105
  credentials: Annotated[
@@ -137,3 +216,7 @@ async def get_optional_user(
137
216
  # Type aliases for dependency injection
138
217
  CurrentUser = Annotated[User, Depends(get_current_user)]
139
218
  OptionalUser = Annotated[User | None, Depends(get_optional_user)]
219
+
220
+ # Optimized variant that skips role loading (saves 1 DB query)
221
+ # Use when role info is not needed for the endpoint
222
+ CurrentUserNoRole = Annotated[User, Depends(current_user(include_role=False))]
@@ -26,6 +26,7 @@ class UserRepository:
26
26
  self,
27
27
  user_id: UUID,
28
28
  for_update: bool = False,
29
+ include_role: bool = True,
29
30
  ) -> User | None:
30
31
  """
31
32
  Get user by ID.
@@ -34,13 +35,16 @@ class UserRepository:
34
35
  user_id: User UUID
35
36
  for_update: If True, lock the row for update (prevents race conditions
36
37
  in operations that depend on current user state like is_active)
38
+ include_role: If True, eagerly load the user's role (default: True).
39
+ Set to False to skip the role query when role info is not needed.
37
40
 
38
41
  Returns:
39
42
  User entity or None if not found
40
43
  """
41
- stmt = (
42
- select(UserModel).options(selectinload(UserModel.role)).where(UserModel.id == user_id)
43
- )
44
+ stmt = select(UserModel).where(UserModel.id == user_id)
45
+
46
+ if include_role:
47
+ stmt = stmt.options(selectinload(UserModel.role))
44
48
 
45
49
  if for_update:
46
50
  stmt = stmt.with_for_update()
@@ -57,6 +61,7 @@ class UserRepository:
57
61
  self,
58
62
  email: str,
59
63
  for_update: bool = False,
64
+ include_role: bool = True,
60
65
  ) -> User | None:
61
66
  """
62
67
  Get user by email address.
@@ -64,13 +69,16 @@ class UserRepository:
64
69
  Args:
65
70
  email: User email
66
71
  for_update: If True, lock the row for update
72
+ include_role: If True, eagerly load the user's role (default: True).
73
+ Set to False to skip the role query when role info is not needed.
67
74
 
68
75
  Returns:
69
76
  User entity or None if not found
70
77
  """
71
- stmt = (
72
- select(UserModel).options(selectinload(UserModel.role)).where(UserModel.email == email)
73
- )
78
+ stmt = select(UserModel).where(UserModel.email == email)
79
+
80
+ if include_role:
81
+ stmt = stmt.options(selectinload(UserModel.role))
74
82
 
75
83
  if for_update:
76
84
  stmt = stmt.with_for_update()
@@ -98,12 +98,19 @@ class AuthService:
98
98
  """Get Google OAuth service."""
99
99
  return self._google_oauth
100
100
 
101
- async def get_user_from_token(self, token: str) -> User:
101
+ async def get_user_from_token(
102
+ self,
103
+ token: str,
104
+ include_role: bool = True,
105
+ ) -> User:
102
106
  """
103
107
  Get user from access token.
104
108
 
105
109
  Args:
106
110
  token: JWT access token
111
+ include_role: If True, eagerly load the user's role (default: True).
112
+ Set to False to skip the role query when role info is not needed,
113
+ reducing database queries for better performance.
107
114
 
108
115
  Returns:
109
116
  User entity
@@ -134,7 +141,7 @@ class AuthService:
134
141
  raise TokenInvalidError("Missing user ID in token")
135
142
 
136
143
  async with self._create_uow() as uow:
137
- user = await uow.users.get_by_id(UUID(user_id))
144
+ user = await uow.users.get_by_id(UUID(user_id), include_role=include_role)
138
145
 
139
146
  if user is None:
140
147
  raise UserNotFoundError()
@@ -0,0 +1,205 @@
1
+ """User plan cache for fast user plan lookups."""
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime, timedelta
7
+ from uuid import UUID
8
+
9
+ from identity_plan_kit.plans.domain.entities import Plan, UserPlan
10
+ from identity_plan_kit.shared.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class UserPlanCacheEntry:
17
+ """Cache entry for user plan with expiration."""
18
+
19
+ user_plan: UserPlan
20
+ plan: Plan
21
+ expires_at: datetime
22
+ fetched_at: float = field(default_factory=time.monotonic)
23
+
24
+ @property
25
+ def is_expired(self) -> bool:
26
+ """Check if entry has expired."""
27
+ return datetime.now(UTC) > self.expires_at
28
+
29
+
30
+ class UserPlanCache:
31
+ """
32
+ In-memory user plan cache.
33
+
34
+ Caches user plan assignments (by user_id) to reduce database queries.
35
+ User plans change less frequently than usage, so caching provides
36
+ significant benefit for quota check operations.
37
+
38
+ Cache key: user_id (UUID)
39
+ Cache value: (UserPlan, Plan) tuple with full plan details
40
+
41
+ **Invalidation:**
42
+
43
+ Call invalidate() when:
44
+ - User's plan is assigned/changed
45
+ - User's plan is cancelled
46
+ - User's plan is extended
47
+ - User's custom limits are updated
48
+
49
+ **TTL:**
50
+
51
+ Default TTL is 5 minutes. This balances:
52
+ - Reducing database load (cache hits avoid 2-4 queries)
53
+ - Ensuring plan changes propagate within reasonable time
54
+ - Memory usage (entries expire and get cleaned up)
55
+ """
56
+
57
+ def __init__(self, ttl_seconds: int = 300) -> None:
58
+ """
59
+ Initialize user plan cache.
60
+
61
+ Args:
62
+ ttl_seconds: Cache TTL in seconds (default: 5 minutes, 0 to disable)
63
+ """
64
+ self._ttl = timedelta(seconds=ttl_seconds)
65
+ self._cache: dict[UUID, UserPlanCacheEntry] = {}
66
+ self._write_lock = asyncio.Lock()
67
+ self._enabled = ttl_seconds > 0
68
+ self._invalidated_at: dict[UUID, float] = {}
69
+ self._global_invalidated_at: float = 0.0
70
+
71
+ async def get(self, user_id: UUID) -> tuple[UserPlan, Plan] | None:
72
+ """
73
+ Get cached user plan by user ID.
74
+
75
+ Args:
76
+ user_id: User UUID
77
+
78
+ Returns:
79
+ Tuple of (UserPlan, Plan) or None if not cached/expired
80
+ """
81
+ if not self._enabled:
82
+ return None
83
+
84
+ entry = self._cache.get(user_id)
85
+
86
+ if entry is None:
87
+ return None
88
+
89
+ if entry.is_expired:
90
+ return None
91
+
92
+ return entry.user_plan, entry.plan
93
+
94
+ def get_fetch_timestamp(self) -> float:
95
+ """
96
+ Get a monotonic timestamp to associate with a DB fetch.
97
+
98
+ Call this BEFORE fetching from the database, then pass the timestamp
99
+ to set() to enable stale write prevention.
100
+
101
+ Returns:
102
+ Monotonic timestamp
103
+ """
104
+ return time.monotonic()
105
+
106
+ async def set(
107
+ self,
108
+ user_id: UUID,
109
+ user_plan: UserPlan,
110
+ plan: Plan,
111
+ fetched_at: float | None = None,
112
+ ) -> bool:
113
+ """
114
+ Cache user plan by user ID.
115
+
116
+ Args:
117
+ user_id: User UUID
118
+ user_plan: UserPlan entity to cache
119
+ plan: Plan entity with full details (permissions, limits)
120
+ fetched_at: Monotonic timestamp when data was fetched from DB
121
+
122
+ Returns:
123
+ True if the entry was cached, False if rejected as stale
124
+ """
125
+ if not self._enabled:
126
+ return False
127
+
128
+ if fetched_at is not None:
129
+ if fetched_at < self._global_invalidated_at:
130
+ logger.debug(
131
+ "user_plan_cache_stale_write_rejected",
132
+ user_id=str(user_id),
133
+ reason="global_invalidation",
134
+ )
135
+ return False
136
+
137
+ key_invalidated_at = self._invalidated_at.get(user_id, 0.0)
138
+ if fetched_at < key_invalidated_at:
139
+ logger.debug(
140
+ "user_plan_cache_stale_write_rejected",
141
+ user_id=str(user_id),
142
+ reason="key_invalidation",
143
+ )
144
+ return False
145
+
146
+ entry = UserPlanCacheEntry(
147
+ user_plan=user_plan,
148
+ plan=plan,
149
+ expires_at=datetime.now(UTC) + self._ttl,
150
+ fetched_at=fetched_at or time.monotonic(),
151
+ )
152
+ self._cache[user_id] = entry
153
+ return True
154
+
155
+ async def invalidate(self, user_id: UUID) -> None:
156
+ """
157
+ Invalidate cached user plan by user ID.
158
+
159
+ Call this when a user's plan changes (assigned, cancelled, extended, etc.).
160
+
161
+ Args:
162
+ user_id: User UUID to invalidate
163
+ """
164
+ self._invalidated_at[user_id] = time.monotonic()
165
+ self._cache.pop(user_id, None)
166
+ logger.debug("user_plan_cache_invalidated", user_id=str(user_id))
167
+
168
+ async def invalidate_all(self) -> None:
169
+ """
170
+ Invalidate all cached user plans.
171
+
172
+ Call this when plans are modified globally.
173
+ """
174
+ async with self._write_lock:
175
+ self._global_invalidated_at = time.monotonic()
176
+ self._cache.clear()
177
+ self._invalidated_at.clear()
178
+ logger.info("user_plan_cache_cleared")
179
+
180
+ async def cleanup_expired(self) -> int:
181
+ """
182
+ Remove expired entries from cache.
183
+
184
+ Returns:
185
+ Number of entries removed
186
+ """
187
+ async with self._write_lock:
188
+ expired_keys = [
189
+ key for key, entry in self._cache.items() if entry.is_expired
190
+ ]
191
+ for key in expired_keys:
192
+ del self._cache[key]
193
+
194
+ if expired_keys:
195
+ logger.debug(
196
+ "user_plan_cache_cleanup",
197
+ removed_count=len(expired_keys),
198
+ )
199
+
200
+ return len(expired_keys)
201
+
202
+ @property
203
+ def size(self) -> int:
204
+ """Get current cache size."""
205
+ return len(self._cache)
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
9
9
 
10
10
  from identity_plan_kit.config import IdentityPlanKitConfig
11
11
  from identity_plan_kit.plans.cache.plan_cache import PlanCache
12
+ from identity_plan_kit.plans.cache.user_plan_cache import UserPlanCache
12
13
  from identity_plan_kit.plans.domain.entities import Feature, Plan, UserPlan
13
14
  from identity_plan_kit.plans.domain.exceptions import (
14
15
  FeatureNotAvailableError,
@@ -88,12 +89,14 @@ class PlanService:
88
89
  config: IdentityPlanKitConfig,
89
90
  session_factory: async_sessionmaker[AsyncSession],
90
91
  plan_cache_ttl_seconds: int = DEFAULT_PLAN_CACHE_TTL_SECONDS,
92
+ user_plan_cache_ttl_seconds: int = DEFAULT_PLAN_CACHE_TTL_SECONDS,
91
93
  state_store_manager: StateStoreManager | None = None,
92
94
  authorization_callback: AuthorizationCallback | None = None,
93
95
  ) -> None:
94
96
  self._config = config
95
97
  self._session_factory = session_factory
96
98
  self._plan_cache = PlanCache(ttl_seconds=plan_cache_ttl_seconds)
99
+ self._user_plan_cache = UserPlanCache(ttl_seconds=user_plan_cache_ttl_seconds)
97
100
  self._state_store_manager = state_store_manager
98
101
  self._authorization_callback = authorization_callback
99
102
 
@@ -234,12 +237,29 @@ class PlanService:
234
237
  Database errors and other infrastructure failures will propagate.
235
238
  """
236
239
  try:
240
+ # Check user plan cache first
241
+ cached_user_plan = await self._user_plan_cache.get(user_id)
242
+ if cached_user_plan is not None:
243
+ _user_plan, plan = cached_user_plan
244
+ limit = plan.get_feature_limit(feature_code)
245
+ return limit is not None
246
+
237
247
  async with self._create_uow(session=session) as uow:
238
- user_plan = await uow.plans.get_user_active_plan(user_id)
239
- if user_plan is None:
248
+ # Fetch from database and cache
249
+ fetch_ts = self._user_plan_cache.get_fetch_timestamp()
250
+ result = await uow.plans.get_user_active_plan_with_details(user_id)
251
+ if result is None:
240
252
  return False
241
253
 
242
- limit = await uow.plans.get_plan_limit(user_plan.plan_id, feature_code)
254
+ user_plan, plan = result
255
+
256
+ # Cache the result
257
+ await self._user_plan_cache.set(
258
+ user_id, user_plan, plan, fetched_at=fetch_ts
259
+ )
260
+
261
+ # Extract limit from already-loaded plan (no extra query needed)
262
+ limit = plan.get_feature_limit(feature_code)
243
263
  return limit is not None
244
264
 
245
265
  except (UserPlanNotFoundError, FeatureNotAvailableError):
@@ -347,17 +367,41 @@ class PlanService:
347
367
  exc_info=True,
348
368
  )
349
369
 
370
+ # Check user plan cache first
371
+ cached_user_plan = await self._user_plan_cache.get(user_id)
372
+ user_plan: UserPlan | None = None
373
+ plan: Plan | None = None
374
+
375
+ if cached_user_plan is not None:
376
+ user_plan, plan = cached_user_plan
377
+ logger.debug(
378
+ "user_plan_cache_hit",
379
+ user_id=str(user_id),
380
+ plan_code=user_plan.plan_code,
381
+ )
382
+
350
383
  async with self._create_uow(session=session) as uow:
351
- # Get user's active plan
352
- user_plan = await uow.plans.get_user_active_plan(user_id)
353
- if user_plan is None:
354
- raise UserPlanNotFoundError()
384
+ # If not in cache, fetch from database
385
+ if user_plan is None or plan is None:
386
+ fetch_ts = self._user_plan_cache.get_fetch_timestamp()
387
+ result = await uow.plans.get_user_active_plan_with_details(user_id)
388
+ if result is None:
389
+ raise UserPlanNotFoundError()
390
+
391
+ user_plan, plan = result
392
+
393
+ # Cache the result
394
+ await self._user_plan_cache.set(
395
+ user_id, user_plan, plan, fetched_at=fetch_ts
396
+ )
355
397
 
356
398
  if user_plan.is_expired:
399
+ # Invalidate cache for expired plans
400
+ await self._user_plan_cache.invalidate(user_id)
357
401
  raise PlanExpiredError()
358
402
 
359
- # Get feature limit
360
- limit = await uow.plans.get_plan_limit(user_plan.plan_id, feature_code)
403
+ # Extract limit from already-loaded plan (no extra query needed)
404
+ limit = plan.get_feature_limit(feature_code)
361
405
  if limit is None:
362
406
  raise FeatureNotAvailableError(feature_code, user_plan.plan_code)
363
407
 
@@ -464,12 +508,31 @@ class PlanService:
464
508
  Returns:
465
509
  UsageInfo with current usage
466
510
  """
511
+ # Check user plan cache first
512
+ cached_user_plan = await self._user_plan_cache.get(user_id)
513
+ user_plan: UserPlan | None = None
514
+ plan: Plan | None = None
515
+
516
+ if cached_user_plan is not None:
517
+ user_plan, plan = cached_user_plan
518
+
467
519
  async with self._create_uow() as uow:
468
- user_plan = await uow.plans.get_user_active_plan(user_id)
469
- if user_plan is None:
470
- raise UserPlanNotFoundError()
520
+ # If not in cache, fetch from database
521
+ if user_plan is None or plan is None:
522
+ fetch_ts = self._user_plan_cache.get_fetch_timestamp()
523
+ result = await uow.plans.get_user_active_plan_with_details(user_id)
524
+ if result is None:
525
+ raise UserPlanNotFoundError()
526
+
527
+ user_plan, plan = result
528
+
529
+ # Cache the result
530
+ await self._user_plan_cache.set(
531
+ user_id, user_plan, plan, fetched_at=fetch_ts
532
+ )
471
533
 
472
- limit = await uow.plans.get_plan_limit(user_plan.plan_id, feature_code)
534
+ # Extract limit from already-loaded plan (no extra query needed)
535
+ limit = plan.get_feature_limit(feature_code)
473
536
  if limit is None:
474
537
  raise FeatureNotAvailableError(feature_code, user_plan.plan_code)
475
538
 
@@ -666,6 +729,9 @@ class PlanService:
666
729
  custom_limits=custom_limits,
667
730
  )
668
731
 
732
+ # Invalidate user plan cache
733
+ await self._user_plan_cache.invalidate(user_id)
734
+
669
735
  logger.info(
670
736
  "plan_assigned",
671
737
  user_id=str(user_id),
@@ -726,6 +792,9 @@ class PlanService:
726
792
  result = await uow.plans.cancel_user_plan(user_id, immediate=immediate)
727
793
 
728
794
  if result:
795
+ # Invalidate user plan cache
796
+ await self._user_plan_cache.invalidate(user_id)
797
+
729
798
  logger.info(
730
799
  "plan_cancelled",
731
800
  user_id=str(user_id),
@@ -795,6 +864,9 @@ class PlanService:
795
864
  if updated_plan is None:
796
865
  raise UserPlanNotFoundError()
797
866
 
867
+ # Invalidate user plan cache
868
+ await self._user_plan_cache.invalidate(user_id)
869
+
798
870
  logger.info(
799
871
  "plan_extended",
800
872
  user_id=str(user_id),
@@ -877,6 +949,9 @@ class PlanService:
877
949
  if updated_plan is None:
878
950
  raise UserPlanNotFoundError()
879
951
 
952
+ # Invalidate user plan cache
953
+ await self._user_plan_cache.invalidate(user_id)
954
+
880
955
  logger.info(
881
956
  "plan_limits_updated",
882
957
  user_id=str(user_id),
@@ -1041,6 +1116,26 @@ class PlanService:
1041
1116
  """
1042
1117
  await self._plan_cache.invalidate_all()
1043
1118
 
1119
+ async def invalidate_user_plan_cache(self, user_id: UUID) -> None:
1120
+ """
1121
+ Invalidate cached user plan by user ID.
1122
+
1123
+ Call this when a user's plan assignment changes.
1124
+
1125
+ Args:
1126
+ user_id: User UUID to invalidate
1127
+ """
1128
+ await self._user_plan_cache.invalidate(user_id)
1129
+ logger.debug("user_plan_cache_invalidated", user_id=str(user_id))
1130
+
1131
+ async def invalidate_all_user_plan_cache(self) -> None:
1132
+ """
1133
+ Invalidate all cached user plans.
1134
+
1135
+ Call this when plans are modified globally (e.g., plan limits change).
1136
+ """
1137
+ await self._user_plan_cache.invalidate_all()
1138
+
1044
1139
  # =========================================================================
1045
1140
  # Plan Listing Methods (for public display)
1046
1141
  # =========================================================================
@@ -962,7 +962,7 @@ wheels = [
962
962
 
963
963
  [[package]]
964
964
  name = "identity-plan-kit"
965
- version = "0.2.5"
965
+ version = "0.2.6"
966
966
  source = { editable = "." }
967
967
  dependencies = [
968
968
  { name = "alembic" },