From f7afe32e13c6d7c2e998d20c1eb89d30a987434d Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:23:25 +0300 Subject: [PATCH] [refactor] --- .dockerignore | 7 +- .github/workflows/ci.yaml | 35 +- .gitignore | 24 +- .pre-commit-config.yaml | 64 +- .yamlfmt | 2 +- Dockerfile | 41 + Makefile | 221 ++-- README.md | 781 +------------ alembic.ini | 81 +- config/dev/.gitkeep | 0 config/local/.env.local | 11 - config/local/.gitkeep | 0 config/local/.secrets.toml | 17 - config/local/Dockerfile | 25 - config/local/config.toml | 43 - config/local/docker-compose.yaml | 45 - config/local/export.toml | 13 - config/prod/.gitkeep | 0 config/toml_config_manager.py | 298 ----- docker-compose.test.yml | 8 + docker-compose.yml | 36 + docker-entrypoint.sh | 14 + docs/Robert_Martin_CA.png | Bin 376479 -> 0 bytes docs/application_controller_interactor.svg | 4 - docs/application_interactor.svg | 4 - docs/application_interactor_adapter.svg | 4 - docs/dep_graph_basic.svg | 4 - docs/dep_graph_inv_correct.svg | 4 - docs/dep_graph_inv_correct_di.svg | 4 - docs/dep_graph_inv_corrupted.svg | 4 - docs/domain_adapter.svg | 4 - .../application_controller_interactor.drawio | 40 - docs/draw.io/application_interactor.drawio | 53 - .../application_interactor_adapter.drawio | 84 -- docs/draw.io/dep_graph_basic.drawio | 43 - docs/draw.io/dep_graph_inv_correct.drawio | 38 - docs/draw.io/dep_graph_inv_correct_di.drawio | 75 -- docs/draw.io/dep_graph_inv_corrupted.drawio | 24 - docs/draw.io/domain_adapter.drawio | 37 - docs/draw.io/identity_provider.drawio | 70 -- .../infrastructure_controller_handler.drawio | 40 - docs/draw.io/infrastructure_handler.drawio | 61 - docs/draw.io/toml_config_manager.drawio | 73 -- docs/handlers.png | Bin 401394 -> 0 bytes docs/identity_provider.svg | 4 - docs/infrastructure_controller_handler.svg | 4 - docs/infrastructure_handler.svg | 4 - docs/onion_1.svg | 4 - docs/onion_2.svg | 4 - docs/toml_config_manager.svg | 4 - env.example | 21 + pyproject.toml | 309 ++--- scripts/dishka/plot_dependencies_data.py | 34 +- scripts/makefile/pycache_del.sh | 3 +- src/app/application/commands/activate_user.py | 93 -- src/app/application/commands/create_user.py | 98 -- .../application/commands/deactivate_user.py | 103 -- src/app/application/commands/grant_admin.py | 83 -- src/app/application/commands/revoke_admin.py | 81 -- .../application/commands/set_user_password.py | 99 -- .../common/exceptions/authorization.py | 5 - src/app/application/common/exceptions/base.py | 2 - .../application/common/exceptions/query.py | 9 - src/app/application/common/ports/flusher.py | 17 - .../common/ports/identity_provider.py | 10 - .../common/ports/user_command_gateway.py | 28 - .../common/ports/user_query_gateway.py | 32 - .../common/query_params/offset_pagination.py | 20 - .../services/authorization/authorize.py | 16 - .../application/common/services/constants.py | 6 - .../common/services/current_user.py | 43 - src/app/application/queries/list_users.py | 81 -- {config => src/app/config}/__init__.py | 0 src/app/config/loader.py | 82 ++ src/app/config/logging_.py | 20 + src/app/config/settings.py | 79 ++ src/app/{application => core}/__init__.py | 0 .../commands/__init__.py | 0 src/app/core/commands/activate_user.py | 82 ++ src/app/core/commands/create_user.py | 98 ++ src/app/core/commands/deactivate_user.py | 89 ++ src/app/core/commands/exceptions.py | 11 + src/app/core/commands/grant_admin.py | 82 ++ .../commands/ports}/__init__.py | 0 src/app/core/commands/ports/flusher.py | 10 + .../commands}/ports/transaction_manager.py | 6 +- .../core/commands/ports/user_tx_storage.py | 20 + src/app/core/commands/ports/utc_timer.py | 10 + src/app/core/commands/revoke_admin.py | 82 ++ src/app/core/commands/set_user_password.py | 85 ++ .../exceptions => core/common}/__init__.py | 0 .../common/authorization}/__init__.py | 0 .../core/common/authorization/authorize.py | 11 + .../common}/authorization/base.py | 2 +- .../common}/authorization/composite.py | 5 +- .../authorization/current_user_service.py | 33 + .../core/common/authorization/exceptions.py | 7 + .../common}/authorization/permissions.py | 31 +- src/app/core/common/authorization/ports.py | 10 + .../common}/authorization/role_hierarchy.py | 4 +- .../common/entities}/__init__.py | 0 .../{domain => core/common}/entities/base.py | 0 src/app/core/common/entities/types_.py | 16 + src/app/core/common/entities/user.py | 29 + src/app/core/common/exceptions.py | 25 + .../common/factories}/__init__.py | 0 src/app/core/common/factories/id_factory.py | 9 + .../common/ports}/__init__.py | 0 .../common/ports/access_revoker.py | 5 +- .../core/common/ports/identity_provider.py | 9 + src/app/core/common/ports/password_hasher.py | 13 + .../common/services}/__init__.py | 0 src/app/core/common/services/user.py | 105 ++ .../common/value_objects}/__init__.py | 0 .../common}/value_objects/base.py | 23 +- .../core/common/value_objects/raw_password.py | 21 + src/app/core/common/value_objects/username.py | 41 + .../core/common/value_objects/utc_datetime.py | 23 + .../entities => core/queries}/__init__.py | 0 src/app/core/queries/list_users.py | 71 ++ .../enums => core/queries/models}/__init__.py | 0 src/app/core/queries/models/user.py | 13 + .../queries/ports}/__init__.py | 0 src/app/core/queries/ports/user_reader.py | 23 + .../queries/query_support}/__init__.py | 0 .../core/queries/query_support/exceptions.py | 9 + .../query_support/offset_pagination.py | 26 + .../queries/query_support}/sorting.py | 0 src/app/domain/entities/user.py | 22 - src/app/domain/enums/user_role.py | 15 - src/app/domain/exceptions/base.py | 6 - src/app/domain/exceptions/user.py | 44 - src/app/domain/ports/password_hasher.py | 19 - src/app/domain/ports/user_id_generator.py | 8 - src/app/domain/services/user.py | 75 -- src/app/domain/value_objects/raw_password.py | 26 - src/app/domain/value_objects/user_id.py | 9 - .../value_objects/user_password_hash.py | 8 - src/app/domain/value_objects/username.py | 69 -- .../adapters/auth_session_access_revoker.py | 11 + .../auth_session_identity_provider.py | 11 + ...er_bcrypt.py => bcrypt_password_hasher.py} | 30 +- src/app/infrastructure/adapters/constants.py | 8 - src/app/infrastructure/adapters/exceptions.py | 5 + .../adapters/main_flusher_sqla.py | 43 - .../adapters/main_transaction_manager_sqla.py | 30 - .../infrastructure/adapters/sqla_flusher.py | 43 + .../adapters/sqla_transaction_manager.py | 26 + .../adapters/sqla_user_reader.py | 76 ++ .../adapters/sqla_user_tx_storage.py | 34 + .../adapters/system_utc_timer.py | 10 + src/app/infrastructure/adapters/types.py | 9 - .../adapters/user_data_mapper_sqla.py | 54 - .../adapters/user_id_generator_uuid.py | 9 - .../adapters/user_reader_sqla.py | 77 -- .../auth/adapters/access_revoker.py | 12 - .../auth/adapters/data_mapper_sqla.py | 61 - .../auth/adapters/identity_provider.py | 12 - .../auth/adapters/transaction_manager_sqla.py | 30 - src/app/infrastructure/auth/exceptions.py | 17 - .../auth/handlers/change_password.py | 75 -- .../infrastructure/auth/handlers/constants.py | 10 - .../infrastructure/auth/handlers/log_in.py | 95 -- .../infrastructure/auth/handlers/log_out.py | 38 - .../infrastructure/auth/handlers/sign_up.py | 90 -- .../auth/session/id_generator_str.py | 6 - .../auth/session/ports/gateway.py | 32 - .../auth/session/ports/transaction_manager.py | 21 - .../auth/session/ports/transport.py | 15 - .../infrastructure/auth/session/service.py | 242 ---- .../infrastructure/auth/session/timer_utc.py | 19 - .../auth_ctx}/__init__.py | 0 .../infrastructure/auth_ctx/cookie_manager.py | 22 + src/app/infrastructure/auth_ctx/exceptions.py | 19 + .../auth_ctx/handlers}/__init__.py | 0 .../auth_ctx/handlers/change_password.py | 61 + .../auth_ctx/handlers/log_in.py | 73 ++ .../auth_ctx/handlers/log_out.py | 29 + .../auth_ctx/handlers/sign_up.py | 78 ++ src/app/infrastructure/auth_ctx/id_factory.py | 7 + .../infrastructure/auth_ctx/jwt_processor.py | 33 + src/app/infrastructure/auth_ctx/jwt_types.py | 10 + .../{auth/session => auth_ctx}/model.py | 13 +- src/app/infrastructure/auth_ctx/service.py | 74 ++ .../auth_ctx/sqla_transaction_manager.py | 25 + .../auth_ctx/sqla_tx_storage.py | 54 + .../auth_ctx/sqla_user_tx_storage.py | 34 + .../adapters/types.py => auth_ctx/types_.py} | 0 src/app/infrastructure/auth_ctx/utc_timer.py | 29 + src/app/infrastructure/exceptions.py | 9 + src/app/infrastructure/exceptions/base.py | 2 - src/app/infrastructure/exceptions/gateway.py | 9 - .../exceptions/password_hasher.py | 5 - .../persistence_sqla/alembic/README | 2 +- .../persistence_sqla/alembic/env.py | 7 +- .../persistence_sqla/alembic/script.py.mako | 4 +- ...2025_06_11_2058-e325187c1eeb_users_auth.py | 52 - .../versions/2026-03-02_221518_users.py | 40 + .../2026-03-02_230628_auth_sessions.py | 38 + .../persistence_sqla/constraint_names.py | 3 + .../persistence_sqla/mappings/all.py | 7 +- .../persistence_sqla/mappings/auth_session.py | 19 +- .../persistence_sqla/mappings/user.py | 31 +- .../{infrastructure/auth => main}/__init__.py | 0 .../auth/adapters => main/ioc}/__init__.py | 0 src/app/main/ioc/core.py | 81 ++ src/app/main/ioc/infrastructure.py | 177 +++ src/app/main/ioc/provider_registry.py | 13 + src/app/main/run.py | 135 +++ src/app/main/setup.py | 36 + .../http/account}/__init__.py | 0 .../http/account/change_password.py | 62 + src/app/presentation/http/account/log_in.py | 44 + src/app/presentation/http/account/log_out.py | 36 + src/app/presentation/http/account/router.py | 15 + src/app/presentation/http/account/sign_up.py | 43 + src/app/presentation/http/api_v1_router.py | 11 + .../http/auth/access_token_processor_jwt.py | 69 -- .../adapters/session_transport_jwt_cookie.py | 61 - .../presentation/http/auth/asgi_middleware.py | 101 -- src/app/presentation/http/auth/constants.py | 18 - .../presentation/http/auth/cookie_params.py | 12 - .../presentation/http/auth/openapi_marker.py | 9 - .../http/auth_cookie_middleware.py | 58 + .../controllers/account/change_password.py | 68 -- .../http/controllers/account/log_in.py | 57 - .../http/controllers/account/log_out.py | 47 - .../http/controllers/account/router.py | 24 - .../http/controllers/account/sign_up.py | 64 -- .../http/controllers/api_v1_router.py | 13 - .../http/controllers/general/health.py | 16 - .../http/controllers/general/router.py | 11 - .../http/controllers/root_router.py | 19 - .../http/controllers/users/activate_user.py | 57 - .../http/controllers/users/create_user.py | 83 -- .../http/controllers/users/deactivate_user.py | 57 - .../http/controllers/users/grant_admin.py | 57 - .../http/controllers/users/list_users.py | 80 -- .../http/controllers/users/revoke_admin.py | 57 - .../http/controllers/users/router.py | 36 - .../controllers/users/set_user_password.py | 67 -- src/app/presentation/http/errors/callbacks.py | 8 +- src/app/presentation/http/errors/rules.py | 11 + .../presentation/http/errors/translators.py | 4 +- .../http/health}/__init__.py | 0 src/app/presentation/http/health/checks.py | 13 + src/app/presentation/http/health/router.py | 43 + src/app/presentation/http/root_router.py | 20 + .../http/users}/__init__.py | 0 .../presentation/http/users/activate_user.py | 42 + .../presentation/http/users/create_user.py | 46 + .../http/users/deactivate_user.py | 42 + .../presentation/http/users/grant_admin.py | 42 + src/app/presentation/http/users/list_users.py | 66 ++ .../presentation/http/users/revoke_admin.py | 42 + src/app/presentation/http/users/router.py | 26 + .../http/users/set_user_password.py | 63 + src/app/run.py | 36 - src/app/setup/app_factory.py | 48 - src/app/setup/config/database.py | 51 - src/app/setup/config/loader.py | 104 -- src/app/setup/config/logs.py | 42 - src/app/setup/config/security.py | 50 - src/app/setup/config/settings.py | 22 - src/app/setup/ioc/application.py | 66 -- src/app/setup/ioc/domain.py | 40 - src/app/setup/ioc/infrastructure.py | 182 --- src/app/setup/ioc/presentation.py | 28 - src/app/setup/ioc/provider_registry.py | 19 - src/app/setup/ioc/settings.py | 28 - .../app/integration/setup/test_cfg_loader.py | 5 - .../authz_service/test_permissions.py | 113 -- tests/app/unit/domain/entities/__init__.py | 0 tests/app/unit/domain/enums/__init__.py | 0 tests/app/unit/domain/enums/test_user_role.py | 27 - tests/app/unit/domain/services/__init__.py | 0 tests/app/unit/domain/services/conftest.py | 21 - tests/app/unit/domain/services/test_user.py | 217 ---- .../app/unit/domain/value_objects/__init__.py | 0 tests/app/unit/factories/__init__.py | 0 tests/app/unit/factories/named_entity.py | 42 - tests/app/unit/factories/settings_data.py | 63 - tests/app/unit/factories/tagged_entity.py | 23 - tests/app/unit/factories/user_entity.py | 26 - tests/app/unit/factories/value_objects.py | 44 - tests/app/unit/infrastructure/__init__.py | 0 tests/app/unit/setup/__init__.py | 0 tests/app/unit/setup/test_cfg_database.py | 60 - tests/app/unit/setup/test_cfg_loader.py | 163 --- tests/app/unit/setup/test_cfg_logs.py | 36 - tests/app/unit/setup/test_cfg_security.py | 36 - .../integration}/__init__.py | 0 .../integration/no_infra}/__init__.py | 0 .../integration/with_infra}/__init__.py | 0 tests/integration/with_infra/conftest.py | 95 ++ tests/integration/with_infra/test_stairway.py | 68 ++ .../performance}/__init__.py | 0 .../profile_bcrypt_password_hasher.py} | 10 +- .../account => tests/sanity}/__init__.py | 0 .../sanity/config}/__init__.py | 0 tests/sanity/config/test_loader.py | 5 + .../users => tests/smoke}/__init__.py | 0 {src/app/setup => tests/unit}/__init__.py | 0 .../setup => tests/unit}/config/__init__.py | 0 tests/unit/config/test_loader.py | 118 ++ tests/unit/config/test_settings.py | 18 + .../setup/ioc => tests/unit/core}/__init__.py | 0 tests/{app => unit/core/common}/__init__.py | 0 .../core/common/authorization}/__init__.py | 0 .../core/common/authorization/factories.py | 15 + .../common/authorization}/permission_stubs.py | 8 +- .../common/authorization}/test_authorize.py | 12 +- .../common/authorization}/test_composite.py | 11 +- .../common/authorization/test_permissions.py | 107 ++ .../core/common/entities}/__init__.py | 0 tests/unit/core/common/entities/factories.py | 42 + .../core/common}/entities/test_base.py | 8 +- tests/unit/core/common/entities/types_.py | 32 + .../core/common/services}/__init__.py | 0 tests/unit/core/common/services/conftest.py | 12 + tests/unit/core/common/services/factories.py | 88 ++ .../core/common}/services/mock_types.py | 6 +- tests/unit/core/common/services/stubs.py | 13 + tests/unit/core/common/services/test_stubs.py | 30 + tests/unit/core/common/services/test_user.py | 264 +++++ .../core/common/value_objects}/__init__.py | 0 .../core/common}/value_objects/test_base.py | 74 +- .../value_objects/test_raw_password.py | 6 +- .../common}/value_objects/test_username.py | 8 +- .../common/value_objects/test_utc_datetime.py | 33 + .../unit/core/common/value_objects/types_.py | 8 + .../core/queries}/__init__.py | 0 .../core/queries/query_support}/__init__.py | 0 .../query_support/test_offset_pagination.py | 37 + .../infrastructure}/__init__.py | 0 .../{app => }/unit/infrastructure/conftest.py | 11 +- .../test_bcrypt_password_hasher.py} | 6 +- uv.lock | 1010 +++++++++++------ 338 files changed, 5756 insertions(+), 7567 deletions(-) create mode 100644 Dockerfile delete mode 100644 config/dev/.gitkeep delete mode 100644 config/local/.env.local delete mode 100644 config/local/.gitkeep delete mode 100644 config/local/.secrets.toml delete mode 100644 config/local/Dockerfile delete mode 100644 config/local/config.toml delete mode 100644 config/local/docker-compose.yaml delete mode 100644 config/local/export.toml delete mode 100644 config/prod/.gitkeep delete mode 100644 config/toml_config_manager.py create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint.sh delete mode 100644 docs/Robert_Martin_CA.png delete mode 100644 docs/application_controller_interactor.svg delete mode 100644 docs/application_interactor.svg delete mode 100644 docs/application_interactor_adapter.svg delete mode 100644 docs/dep_graph_basic.svg delete mode 100644 docs/dep_graph_inv_correct.svg delete mode 100644 docs/dep_graph_inv_correct_di.svg delete mode 100644 docs/dep_graph_inv_corrupted.svg delete mode 100644 docs/domain_adapter.svg delete mode 100644 docs/draw.io/application_controller_interactor.drawio delete mode 100644 docs/draw.io/application_interactor.drawio delete mode 100644 docs/draw.io/application_interactor_adapter.drawio delete mode 100644 docs/draw.io/dep_graph_basic.drawio delete mode 100644 docs/draw.io/dep_graph_inv_correct.drawio delete mode 100644 docs/draw.io/dep_graph_inv_correct_di.drawio delete mode 100644 docs/draw.io/dep_graph_inv_corrupted.drawio delete mode 100644 docs/draw.io/domain_adapter.drawio delete mode 100644 docs/draw.io/identity_provider.drawio delete mode 100644 docs/draw.io/infrastructure_controller_handler.drawio delete mode 100644 docs/draw.io/infrastructure_handler.drawio delete mode 100644 docs/draw.io/toml_config_manager.drawio delete mode 100644 docs/handlers.png delete mode 100644 docs/identity_provider.svg delete mode 100644 docs/infrastructure_controller_handler.svg delete mode 100644 docs/infrastructure_handler.svg delete mode 100644 docs/onion_1.svg delete mode 100644 docs/onion_2.svg delete mode 100644 docs/toml_config_manager.svg create mode 100644 env.example delete mode 100644 src/app/application/commands/activate_user.py delete mode 100644 src/app/application/commands/create_user.py delete mode 100644 src/app/application/commands/deactivate_user.py delete mode 100644 src/app/application/commands/grant_admin.py delete mode 100644 src/app/application/commands/revoke_admin.py delete mode 100644 src/app/application/commands/set_user_password.py delete mode 100644 src/app/application/common/exceptions/authorization.py delete mode 100644 src/app/application/common/exceptions/base.py delete mode 100644 src/app/application/common/exceptions/query.py delete mode 100644 src/app/application/common/ports/flusher.py delete mode 100644 src/app/application/common/ports/identity_provider.py delete mode 100644 src/app/application/common/ports/user_command_gateway.py delete mode 100644 src/app/application/common/ports/user_query_gateway.py delete mode 100644 src/app/application/common/query_params/offset_pagination.py delete mode 100644 src/app/application/common/services/authorization/authorize.py delete mode 100644 src/app/application/common/services/constants.py delete mode 100644 src/app/application/common/services/current_user.py delete mode 100644 src/app/application/queries/list_users.py rename {config => src/app/config}/__init__.py (100%) create mode 100644 src/app/config/loader.py create mode 100644 src/app/config/logging_.py create mode 100644 src/app/config/settings.py rename src/app/{application => core}/__init__.py (100%) rename src/app/{application => core}/commands/__init__.py (100%) create mode 100644 src/app/core/commands/activate_user.py create mode 100644 src/app/core/commands/create_user.py create mode 100644 src/app/core/commands/deactivate_user.py create mode 100644 src/app/core/commands/exceptions.py create mode 100644 src/app/core/commands/grant_admin.py rename src/app/{application/common => core/commands/ports}/__init__.py (100%) create mode 100644 src/app/core/commands/ports/flusher.py rename src/app/{application/common => core/commands}/ports/transaction_manager.py (73%) create mode 100644 src/app/core/commands/ports/user_tx_storage.py create mode 100644 src/app/core/commands/ports/utc_timer.py create mode 100644 src/app/core/commands/revoke_admin.py create mode 100644 src/app/core/commands/set_user_password.py rename src/app/{application/common/exceptions => core/common}/__init__.py (100%) rename src/app/{application/common/ports => core/common/authorization}/__init__.py (100%) create mode 100644 src/app/core/common/authorization/authorize.py rename src/app/{application/common/services => core/common}/authorization/base.py (86%) rename src/app/{application/common/services => core/common}/authorization/composite.py (72%) create mode 100644 src/app/core/common/authorization/current_user_service.py create mode 100644 src/app/core/common/authorization/exceptions.py rename src/app/{application/common/services => core/common}/authorization/permissions.py (62%) create mode 100644 src/app/core/common/authorization/ports.py rename src/app/{application/common/services => core/common}/authorization/role_hierarchy.py (62%) rename src/app/{application/common/query_params => core/common/entities}/__init__.py (100%) rename src/app/{domain => core/common}/entities/base.py (100%) create mode 100644 src/app/core/common/entities/types_.py create mode 100644 src/app/core/common/entities/user.py create mode 100644 src/app/core/common/exceptions.py rename src/app/{application/common/services => core/common/factories}/__init__.py (100%) create mode 100644 src/app/core/common/factories/id_factory.py rename src/app/{application/common/services/authorization => core/common/ports}/__init__.py (100%) rename src/app/{application => core}/common/ports/access_revoker.py (64%) create mode 100644 src/app/core/common/ports/identity_provider.py create mode 100644 src/app/core/common/ports/password_hasher.py rename src/app/{application/queries => core/common/services}/__init__.py (100%) create mode 100644 src/app/core/common/services/user.py rename src/app/{domain => core/common/value_objects}/__init__.py (100%) rename src/app/{domain => core/common}/value_objects/base.py (68%) create mode 100644 src/app/core/common/value_objects/raw_password.py create mode 100644 src/app/core/common/value_objects/username.py create mode 100644 src/app/core/common/value_objects/utc_datetime.py rename src/app/{domain/entities => core/queries}/__init__.py (100%) create mode 100644 src/app/core/queries/list_users.py rename src/app/{domain/enums => core/queries/models}/__init__.py (100%) create mode 100644 src/app/core/queries/models/user.py rename src/app/{domain/exceptions => core/queries/ports}/__init__.py (100%) create mode 100644 src/app/core/queries/ports/user_reader.py rename src/app/{domain/ports => core/queries/query_support}/__init__.py (100%) create mode 100644 src/app/core/queries/query_support/exceptions.py create mode 100644 src/app/core/queries/query_support/offset_pagination.py rename src/app/{application/common/query_params => core/queries/query_support}/sorting.py (100%) delete mode 100644 src/app/domain/entities/user.py delete mode 100644 src/app/domain/enums/user_role.py delete mode 100644 src/app/domain/exceptions/base.py delete mode 100644 src/app/domain/exceptions/user.py delete mode 100644 src/app/domain/ports/password_hasher.py delete mode 100644 src/app/domain/ports/user_id_generator.py delete mode 100644 src/app/domain/services/user.py delete mode 100644 src/app/domain/value_objects/raw_password.py delete mode 100644 src/app/domain/value_objects/user_id.py delete mode 100644 src/app/domain/value_objects/user_password_hash.py delete mode 100644 src/app/domain/value_objects/username.py create mode 100644 src/app/infrastructure/adapters/auth_session_access_revoker.py create mode 100644 src/app/infrastructure/adapters/auth_session_identity_provider.py rename src/app/infrastructure/adapters/{password_hasher_bcrypt.py => bcrypt_password_hasher.py} (76%) delete mode 100644 src/app/infrastructure/adapters/constants.py create mode 100644 src/app/infrastructure/adapters/exceptions.py delete mode 100644 src/app/infrastructure/adapters/main_flusher_sqla.py delete mode 100644 src/app/infrastructure/adapters/main_transaction_manager_sqla.py create mode 100644 src/app/infrastructure/adapters/sqla_flusher.py create mode 100644 src/app/infrastructure/adapters/sqla_transaction_manager.py create mode 100644 src/app/infrastructure/adapters/sqla_user_reader.py create mode 100644 src/app/infrastructure/adapters/sqla_user_tx_storage.py create mode 100644 src/app/infrastructure/adapters/system_utc_timer.py delete mode 100644 src/app/infrastructure/adapters/types.py delete mode 100644 src/app/infrastructure/adapters/user_data_mapper_sqla.py delete mode 100644 src/app/infrastructure/adapters/user_id_generator_uuid.py delete mode 100644 src/app/infrastructure/adapters/user_reader_sqla.py delete mode 100644 src/app/infrastructure/auth/adapters/access_revoker.py delete mode 100644 src/app/infrastructure/auth/adapters/data_mapper_sqla.py delete mode 100644 src/app/infrastructure/auth/adapters/identity_provider.py delete mode 100644 src/app/infrastructure/auth/adapters/transaction_manager_sqla.py delete mode 100644 src/app/infrastructure/auth/exceptions.py delete mode 100644 src/app/infrastructure/auth/handlers/change_password.py delete mode 100644 src/app/infrastructure/auth/handlers/constants.py delete mode 100644 src/app/infrastructure/auth/handlers/log_in.py delete mode 100644 src/app/infrastructure/auth/handlers/log_out.py delete mode 100644 src/app/infrastructure/auth/handlers/sign_up.py delete mode 100644 src/app/infrastructure/auth/session/id_generator_str.py delete mode 100644 src/app/infrastructure/auth/session/ports/gateway.py delete mode 100644 src/app/infrastructure/auth/session/ports/transaction_manager.py delete mode 100644 src/app/infrastructure/auth/session/ports/transport.py delete mode 100644 src/app/infrastructure/auth/session/service.py delete mode 100644 src/app/infrastructure/auth/session/timer_utc.py rename src/app/{domain/services => infrastructure/auth_ctx}/__init__.py (100%) create mode 100644 src/app/infrastructure/auth_ctx/cookie_manager.py create mode 100644 src/app/infrastructure/auth_ctx/exceptions.py rename src/app/{domain/value_objects => infrastructure/auth_ctx/handlers}/__init__.py (100%) create mode 100644 src/app/infrastructure/auth_ctx/handlers/change_password.py create mode 100644 src/app/infrastructure/auth_ctx/handlers/log_in.py create mode 100644 src/app/infrastructure/auth_ctx/handlers/log_out.py create mode 100644 src/app/infrastructure/auth_ctx/handlers/sign_up.py create mode 100644 src/app/infrastructure/auth_ctx/id_factory.py create mode 100644 src/app/infrastructure/auth_ctx/jwt_processor.py create mode 100644 src/app/infrastructure/auth_ctx/jwt_types.py rename src/app/infrastructure/{auth/session => auth_ctx}/model.py (56%) create mode 100644 src/app/infrastructure/auth_ctx/service.py create mode 100644 src/app/infrastructure/auth_ctx/sqla_transaction_manager.py create mode 100644 src/app/infrastructure/auth_ctx/sqla_tx_storage.py create mode 100644 src/app/infrastructure/auth_ctx/sqla_user_tx_storage.py rename src/app/infrastructure/{auth/adapters/types.py => auth_ctx/types_.py} (100%) create mode 100644 src/app/infrastructure/auth_ctx/utc_timer.py create mode 100644 src/app/infrastructure/exceptions.py delete mode 100644 src/app/infrastructure/exceptions/base.py delete mode 100644 src/app/infrastructure/exceptions/gateway.py delete mode 100644 src/app/infrastructure/exceptions/password_hasher.py delete mode 100644 src/app/infrastructure/persistence_sqla/alembic/versions/2025_06_11_2058-e325187c1eeb_users_auth.py create mode 100644 src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_221518_users.py create mode 100644 src/app/infrastructure/persistence_sqla/alembic/versions/2026-03-02_230628_auth_sessions.py create mode 100644 src/app/infrastructure/persistence_sqla/constraint_names.py rename src/app/{infrastructure/auth => main}/__init__.py (100%) rename src/app/{infrastructure/auth/adapters => main/ioc}/__init__.py (100%) create mode 100644 src/app/main/ioc/core.py create mode 100644 src/app/main/ioc/infrastructure.py create mode 100644 src/app/main/ioc/provider_registry.py create mode 100644 src/app/main/run.py create mode 100644 src/app/main/setup.py rename src/app/{infrastructure/auth/handlers => presentation/http/account}/__init__.py (100%) create mode 100644 src/app/presentation/http/account/change_password.py create mode 100644 src/app/presentation/http/account/log_in.py create mode 100644 src/app/presentation/http/account/log_out.py create mode 100644 src/app/presentation/http/account/router.py create mode 100644 src/app/presentation/http/account/sign_up.py create mode 100644 src/app/presentation/http/api_v1_router.py delete mode 100644 src/app/presentation/http/auth/access_token_processor_jwt.py delete mode 100644 src/app/presentation/http/auth/adapters/session_transport_jwt_cookie.py delete mode 100644 src/app/presentation/http/auth/asgi_middleware.py delete mode 100644 src/app/presentation/http/auth/constants.py delete mode 100644 src/app/presentation/http/auth/cookie_params.py delete mode 100644 src/app/presentation/http/auth/openapi_marker.py create mode 100644 src/app/presentation/http/auth_cookie_middleware.py delete mode 100644 src/app/presentation/http/controllers/account/change_password.py delete mode 100644 src/app/presentation/http/controllers/account/log_in.py delete mode 100644 src/app/presentation/http/controllers/account/log_out.py delete mode 100644 src/app/presentation/http/controllers/account/router.py delete mode 100644 src/app/presentation/http/controllers/account/sign_up.py delete mode 100644 src/app/presentation/http/controllers/api_v1_router.py delete mode 100644 src/app/presentation/http/controllers/general/health.py delete mode 100644 src/app/presentation/http/controllers/general/router.py delete mode 100644 src/app/presentation/http/controllers/root_router.py delete mode 100644 src/app/presentation/http/controllers/users/activate_user.py delete mode 100644 src/app/presentation/http/controllers/users/create_user.py delete mode 100644 src/app/presentation/http/controllers/users/deactivate_user.py delete mode 100644 src/app/presentation/http/controllers/users/grant_admin.py delete mode 100644 src/app/presentation/http/controllers/users/list_users.py delete mode 100644 src/app/presentation/http/controllers/users/revoke_admin.py delete mode 100644 src/app/presentation/http/controllers/users/router.py delete mode 100644 src/app/presentation/http/controllers/users/set_user_password.py create mode 100644 src/app/presentation/http/errors/rules.py rename src/app/{infrastructure/auth/session => presentation/http/health}/__init__.py (100%) create mode 100644 src/app/presentation/http/health/checks.py create mode 100644 src/app/presentation/http/health/router.py create mode 100644 src/app/presentation/http/root_router.py rename src/app/{infrastructure/auth/session/ports => presentation/http/users}/__init__.py (100%) create mode 100644 src/app/presentation/http/users/activate_user.py create mode 100644 src/app/presentation/http/users/create_user.py create mode 100644 src/app/presentation/http/users/deactivate_user.py create mode 100644 src/app/presentation/http/users/grant_admin.py create mode 100644 src/app/presentation/http/users/list_users.py create mode 100644 src/app/presentation/http/users/revoke_admin.py create mode 100644 src/app/presentation/http/users/router.py create mode 100644 src/app/presentation/http/users/set_user_password.py delete mode 100644 src/app/run.py delete mode 100644 src/app/setup/app_factory.py delete mode 100644 src/app/setup/config/database.py delete mode 100644 src/app/setup/config/loader.py delete mode 100644 src/app/setup/config/logs.py delete mode 100644 src/app/setup/config/security.py delete mode 100644 src/app/setup/config/settings.py delete mode 100644 src/app/setup/ioc/application.py delete mode 100644 src/app/setup/ioc/domain.py delete mode 100644 src/app/setup/ioc/infrastructure.py delete mode 100644 src/app/setup/ioc/presentation.py delete mode 100644 src/app/setup/ioc/provider_registry.py delete mode 100644 src/app/setup/ioc/settings.py delete mode 100644 tests/app/integration/setup/test_cfg_loader.py delete mode 100644 tests/app/unit/application/authz_service/test_permissions.py delete mode 100644 tests/app/unit/domain/entities/__init__.py delete mode 100644 tests/app/unit/domain/enums/__init__.py delete mode 100644 tests/app/unit/domain/enums/test_user_role.py delete mode 100644 tests/app/unit/domain/services/__init__.py delete mode 100644 tests/app/unit/domain/services/conftest.py delete mode 100644 tests/app/unit/domain/services/test_user.py delete mode 100644 tests/app/unit/domain/value_objects/__init__.py delete mode 100644 tests/app/unit/factories/__init__.py delete mode 100644 tests/app/unit/factories/named_entity.py delete mode 100644 tests/app/unit/factories/settings_data.py delete mode 100644 tests/app/unit/factories/tagged_entity.py delete mode 100644 tests/app/unit/factories/user_entity.py delete mode 100644 tests/app/unit/factories/value_objects.py delete mode 100644 tests/app/unit/infrastructure/__init__.py delete mode 100644 tests/app/unit/setup/__init__.py delete mode 100644 tests/app/unit/setup/test_cfg_database.py delete mode 100644 tests/app/unit/setup/test_cfg_loader.py delete mode 100644 tests/app/unit/setup/test_cfg_logs.py delete mode 100644 tests/app/unit/setup/test_cfg_security.py rename {src/app/infrastructure/exceptions => tests/integration}/__init__.py (100%) rename {src/app/presentation/http/auth => tests/integration/no_infra}/__init__.py (100%) rename {src/app/presentation/http/auth/adapters => tests/integration/with_infra}/__init__.py (100%) create mode 100644 tests/integration/with_infra/conftest.py create mode 100644 tests/integration/with_infra/test_stairway.py rename {src/app/presentation/http/controllers => tests/performance}/__init__.py (100%) rename tests/{app/performance/profile_password_hasher_bcrypt.py => performance/profile_bcrypt_password_hasher.py} (72%) rename {src/app/presentation/http/controllers/account => tests/sanity}/__init__.py (100%) rename {src/app/presentation/http/controllers/general => tests/sanity/config}/__init__.py (100%) create mode 100644 tests/sanity/config/test_loader.py rename {src/app/presentation/http/controllers/users => tests/smoke}/__init__.py (100%) rename {src/app/setup => tests/unit}/__init__.py (100%) rename {src/app/setup => tests/unit}/config/__init__.py (100%) create mode 100644 tests/unit/config/test_loader.py create mode 100644 tests/unit/config/test_settings.py rename {src/app/setup/ioc => tests/unit/core}/__init__.py (100%) rename tests/{app => unit/core/common}/__init__.py (100%) rename tests/{app/integration => unit/core/common/authorization}/__init__.py (100%) create mode 100644 tests/unit/core/common/authorization/factories.py rename tests/{app/unit/application/authz_service => unit/core/common/authorization}/permission_stubs.py (67%) rename tests/{app/unit/application/authz_service => unit/core/common/authorization}/test_authorize.py (58%) rename tests/{app/unit/application/authz_service => unit/core/common/authorization}/test_composite.py (68%) create mode 100644 tests/unit/core/common/authorization/test_permissions.py rename tests/{app/integration/setup => unit/core/common/entities}/__init__.py (100%) create mode 100644 tests/unit/core/common/entities/factories.py rename tests/{app/unit/domain => unit/core/common}/entities/test_base.py (91%) create mode 100644 tests/unit/core/common/entities/types_.py rename tests/{app/performance => unit/core/common/services}/__init__.py (100%) create mode 100644 tests/unit/core/common/services/conftest.py create mode 100644 tests/unit/core/common/services/factories.py rename tests/{app/unit/domain => unit/core/common}/services/mock_types.py (51%) create mode 100644 tests/unit/core/common/services/stubs.py create mode 100644 tests/unit/core/common/services/test_stubs.py create mode 100644 tests/unit/core/common/services/test_user.py rename tests/{app/unit => unit/core/common/value_objects}/__init__.py (100%) rename tests/{app/unit/domain => unit/core/common}/value_objects/test_base.py (60%) rename tests/{app/unit/domain => unit/core/common}/value_objects/test_raw_password.py (60%) rename tests/{app/unit/domain => unit/core/common}/value_objects/test_username.py (86%) create mode 100644 tests/unit/core/common/value_objects/test_utc_datetime.py create mode 100644 tests/unit/core/common/value_objects/types_.py rename tests/{app/unit/application => unit/core/queries}/__init__.py (100%) rename tests/{app/unit/application/authz_service => unit/core/queries/query_support}/__init__.py (100%) create mode 100644 tests/unit/core/queries/query_support/test_offset_pagination.py rename tests/{app/unit/domain => unit/infrastructure}/__init__.py (100%) rename tests/{app => }/unit/infrastructure/conftest.py (78%) rename tests/{app/unit/infrastructure/test_password_hasher_bcrypt.py => unit/infrastructure/test_bcrypt_password_hasher.py} (92%) diff --git a/.dockerignore b/.dockerignore index 78b7c1de..71d3a94a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,9 +2,10 @@ ** # Except +!docker-entrypoint.sh !pyproject.toml -!uv.lock !README.md -!config/** -!src/** +!uv.lock !alembic.ini +!src/** +!tests/** diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 37f9aa54..be82dffc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,43 +7,24 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Install dependencies run: uv sync --locked --group dev - name: Check code - run: uv run make code.check + run: uv run make check - - name: Test Docker Compose setup + - name: Test with Docker env: - APP_ENV: local - run: | - uv run config/toml_config_manager.py - cd config/local - docker compose --env-file .env.local up -d --build - - - name: Verify Application Health - run: | - timeout 10s bash -s <<'BASH' - while ! curl -sf http://127.0.0.1:9999/api/v1/health; do - sleep 1 - done - BASH - - - name: Test Signup Handler - run: | - curl -f --json @- http://127.0.0.1:9999/api/v1/account/signup <<'JSON' - { - "username": "string", - "password": "string" - } - JSON + ALLOW_DESTRUCTIVE_TEST_CLEANUP: 1 + run: make test-docker diff --git a/.gitignore b/.gitignore index c2da5faa..3726ba47 100644 --- a/.gitignore +++ b/.gitignore @@ -159,18 +159,16 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ -# Config -config/local/* -!config/local/.gitkeep -config/dev/* -!config/dev/.gitkeep -config/prod/* -!config/prod/.gitkeep -.secrets.* -.env.* - -# IgnoreToDo +# other +.claude/ +.import_linter_cache/ +.ruff_cache/ +.vscode/ +htmlcov-docker/ todo/ -# ImportLinter -.import_linter_cache/ \ No newline at end of file +.constraints.in +.secrets +AGENTS.md +CLAUDE.md +pylock.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 175d857c..d9a97c98 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,70 @@ +default_language_version: + python: python3.13 + +default_stages: [pre-commit] + repos: - repo: local hooks: - - id: make-check - name: source-code-check - entry: make code.check + - id: code-check + name: code-check (local) + entry: uv run make check + language: system + pass_filenames: false + - id: pip-audit-local + name: pip-audit (local) + entry: uv run make pip-audit + language: system + pass_filenames: false + verbose: true + - id: test-docker + name: test-docker (local) + entry: make test-docker language: system + stages: [pre-push] pass_filenames: false - always_run: true + verbose: true + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-ast + - id: check-case-conflict + - id: trailing-whitespace + - id: end-of-file-fixer + exclude: ^docs/ + - id: check-added-large-files + - id: check-docstring-first + - id: check-json + - id: check-toml + - id: check-yaml + exclude: docker-compose.test.yml + - id: detect-private-key + - id: debug-statements + - id: check-merge-conflict + - id: mixed-line-ending + args: ["--fix=lf"] + - id: no-commit-to-branch + args: [--branch, develop, --branch, dev, --branch, master, --branch, main] + + - repo: https://github.com/crate-ci/typos + rev: v1.40.0 + hooks: + - id: typos + args: [--force-exclude] - repo: https://github.com/google/yamlfmt rev: v0.20.0 hooks: - id: yamlfmt + name: YAML formatter + files: (^|/).*\.ya?ml$ + args: + - "--conf" + - ".yamlfmt" + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.11.0 + hooks: + - id: shellcheck + args: ["--severity=warning"] diff --git a/.yamlfmt b/.yamlfmt index 9a6db598..3620e44b 100644 --- a/.yamlfmt +++ b/.yamlfmt @@ -4,4 +4,4 @@ formatter: type: basic line_ending: lf retain_line_breaks: true - scan_folded_as_literal: true \ No newline at end of file + scan_folded_as_literal: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a47ded36 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +ARG PYTHON_VERSION=3.13 +FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-trixie-slim + +ARG APP_VERSION=develop +ARG ENVIRONMENT="prod" + +ENV APP_VERSION=${APP_VERSION} +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV UV_HTTP_TIMEOUT=300 +ENV UV_LINK_MODE=copy +ENV UV_PROJECT_ENVIRONMENT=/usr/local + +WORKDIR /code + +COPY pyproject.toml uv.lock README.md ./ + +RUN if [ "${ENVIRONMENT}" = "prod" ]; then \ + uv sync --frozen --no-cache --no-dev --no-install-project; \ + else \ + uv sync --frozen --dev --no-install-project; \ + fi + +COPY . . + +RUN if [ "${ENVIRONMENT}" = "prod" ]; then \ + uv sync --frozen --no-cache --no-dev; \ + else \ + uv sync --frozen --dev; \ + fi + +RUN groupadd -r runner +RUN useradd -r -g runner -m -s /usr/sbin/nologin runner +RUN chown -R runner:root /code +RUN chmod -R g=u /code + +USER runner + +EXPOSE 8000 + +ENTRYPOINT [ "/code/docker-entrypoint.sh" ] diff --git a/Makefile b/Makefile index d099c00a..20fffccb 100644 --- a/Makefile +++ b/Makefile @@ -1,100 +1,169 @@ +# Shell / Make config +SHELL := bash +.SHELLFLAGS := -eu -o pipefail -c + +.SILENT: +MAKEFLAGS += --no-print-directory + +# ----------------------------- +# User-configurable variables (edit this) +# ----------------------------- +PROJECT_NAME ?= $(notdir $(abspath .)) +INFRA_SERVICES ?= db_pg + +# ----------------------------- +# Internal vars / aliases +# ----------------------------- +PYTHON_BIN := python +DOCKER_COMPOSE := docker compose -p $(PROJECT_NAME) +DOCKER_COMPOSE_PRUNE := scripts/makefile/docker_prune.sh + +# Test stack is isolated by project name +TEST_PROJECT ?= $(PROJECT_NAME)-test +DC_TEST_DOCKER := docker compose \ + -p $(TEST_PROJECT) \ + -f docker-compose.yml \ + -f docker-compose.test.yml +TEST_RUNNER := $(TEST_PROJECT)-runner + +# Pytest paths +PYTEST_PATHS_LIGHT := \ + tests/sanity \ + tests/unit \ + tests/integration/no_infra +PYTEST_PATHS_ALL := \ + $(PYTEST_PATHS_LIGHT) \ + tests/smoke \ + tests/integration/with_infra + +# Pytest args +PYTEST_ARGS_VERBOSE := -s -vv +PYTEST_ARGS_COV := \ + --cov=src \ + --cov-report=term-missing \ + --cov-report=html +PYTEST_ARGS_COV_DOCKER := \ + --cov=src \ + --cov-report=term-missing + +# Safety +.PHONY: pip-audit +pip-audit: + tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \ + uv -qq export --format pylock.toml -o "$$tmp/pylock.toml"; \ + pip-audit --locked "$$tmp" \ + || echo "WARNING: pip-audit found vulnerabilities (non-blocking)" >&2 + # Code quality -.PHONY: code.format code.lint code.test code.cov code.cov.html code.check -code.format: +.PHONY: slotscheck lint test check +slotscheck: + slotscheck $(SLOTSCHECK_TARGET) 2>&1 | tee /dev/stderr \ + | { grep -m1 "Failed to import" || true; } | cut -d"'" -f2 \ + | xargs -r -n1 $(PYTHON_BIN) -c 'import importlib,sys; importlib.import_module(sys.argv[1])' + +lint: + ruff check --fix ruff format - -code.lint: code.format + tombi format + tombi lint deptry - slotscheck src + $(MAKE) slotscheck SLOTSCHECK_TARGET=src lint-imports - ruff check --exit-non-zero-on-fix mypy -code.test: - pytest -v - -code.cov: - coverage run -m pytest - coverage combine - coverage report +test: + pytest -v \ + $(PYTEST_PATHS_LIGHT) \ + $(PYTEST_ARGS_COV) -code.cov.html: - coverage run -m pytest - coverage combine +check: lint test coverage html -code.check: code.lint code.test - -# Environment -PYTHON := python -CONFIGS_DIG := config -TOML_CONFIG_MANAGER := $(CONFIGS_DIG)/toml_config_manager.py - -.PHONY: guard-APP_ENV -guard-APP_ENV: - @if [ -z "$$APP_ENV" ]; then \ - echo "APP_ENV is not set. Set APP_ENV before running this command."; \ - exit 1; \ - fi - -.PHONY: env dotenv -env: - @echo APP_ENV=$(APP_ENV) - -dotenv: guard-APP_ENV - @$(PYTHON) $(TOML_CONFIG_MANAGER) $(APP_ENV) - # Docker compose -DOCKER_COMPOSE := docker compose -DOCKER_COMPOSE_PRUNE := scripts/makefile/docker_prune.sh - -.PHONY: up.db up.db-echo up up.echo down down.total logs.db shell.db prune -up.db: guard-APP_ENV - @echo "APP_ENV=$(APP_ENV)" - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up -d web_app_db_pg - -up.db-echo: guard-APP_ENV - @echo "APP_ENV=$(APP_ENV)" - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up web_app_db_pg - -up: guard-APP_ENV - @echo "APP_ENV=$(APP_ENV)" - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up -d --build - -up.echo: guard-APP_ENV - @echo "APP_ENV=$(APP_ENV)" - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) up --build - -down.db: guard-APP_ENV - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down web_app_db_pg - -down: guard-APP_ENV - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down - -down.total: guard-APP_ENV - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) down -v - -logs.db: guard-APP_ENV - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) logs -f web_app_db_pg - -shell.db: guard-APP_ENV - @cd $(CONFIGS_DIG)/$(APP_ENV) && $(DOCKER_COMPOSE) --env-file .env.$(APP_ENV) exec web_app_db_pg sh - +.PHONY: docker-env local-env upd up upd-local up-local down stop-all +docker-env: + { \ + echo "# This .env file is generated automatically for DOCKER environment by Makefile."; \ + echo "# Do not edit it directly; edit env.example / .secrets and Makefile instead."; \ + echo; \ + cat env.example; \ + if [ -f .secrets ]; then \ + echo; \ + echo "# --- secrets from .secrets (not committed) ---"; \ + cat .secrets; \ + fi; \ + } > .env + +local-env: + { \ + echo "# This .env file is generated automatically for LOCAL environment by Makefile."; \ + echo "# Do not edit it directly; edit env.example / .secrets and Makefile instead."; \ + echo; \ + sed \ + -e 's|^EXAMPLE_SERVICE_URL=.*|EXAMPLE_SERVICE_URL=http://127.0.0.1:51999|' \ + -e 's|^POSTGRES_HOST=.*|POSTGRES_HOST=127.0.0.1|' \ + env.example; \ + if [ -f .secrets ]; then \ + echo; \ + echo "# --- secrets from .secrets (not committed) ---"; \ + cat .secrets; \ + fi; \ + } > .env + +upd: docker-env + $(DOCKER_COMPOSE) up -d --build --force-recreate + +up: docker-env + $(DOCKER_COMPOSE) up --build --force-recreate + +upd-local: local-env + $(DOCKER_COMPOSE) up -d --build --force-recreate $(INFRA_SERVICES) + +up-local: local-env + $(DOCKER_COMPOSE) up --build --force-recreate $(INFRA_SERVICES) + +down: + $(DOCKER_COMPOSE) down + +stop-all: + docker ps -q | xargs -r docker stop + +# Tests (with infra) +.PHONY: test-docker +test-docker: docker-env + rc=0; \ + $(DC_TEST_DOCKER) down -v --remove-orphans >/dev/null 2>&1 || true; \ + if [ -n "$(strip $(INFRA_SERVICES))" ]; then \ + $(DC_TEST_DOCKER) up -d --build --wait --wait-timeout 180 $(INFRA_SERVICES); \ + else \ + echo "INFRA_SERVICES is empty, skipping infra startup"; \ + fi; \ + $(DC_TEST_DOCKER) run --build --name $(TEST_RUNNER) app \ + pytest $(PYTEST_ARGS_VERBOSE) \ + $(PYTEST_PATHS_ALL) \ + $(PYTEST_ARGS_COV_DOCKER) \ + || rc=$$?; \ + docker cp $(TEST_RUNNER):/tmp/.coverage ./.coverage.docker 2>/dev/null || true; \ + docker rm $(TEST_RUNNER) >/dev/null 2>&1 || true; \ + $(DC_TEST_DOCKER) down -v --remove-orphans; \ + coverage html --data-file=.coverage.docker -d htmlcov-docker && \ + echo "Coverage HTML report: htmlcov-docker/index.html" || true; \ + exit $$rc + +.PHONY: prune prune: $(DOCKER_COMPOSE_PRUNE) # Project structure visualization +.PHONY: pycache-del tree plot-data PYCACHE_DEL := scripts/makefile/pycache_del.sh DISHKA_PLOT_DATA := scripts/dishka/plot_dependencies_data.py -.PHONY: pycache-del tree plot-data pycache-del: @$(PYCACHE_DEL) -# Clean tree tree: pycache-del @tree -# Dishka plot-data: - @$(PYTHON) $(DISHKA_PLOT_DATA) + @$(PYTHON_BIN) $(DISHKA_PLOT_DATA) diff --git a/README.md b/README.md index 12a00f64..1c5ffbf3 100644 --- a/README.md +++ b/README.md @@ -1,771 +1,54 @@ -# Overview +[](https://github.com/mjhea0/awesome-fastapi?tab=readme-ov-file#best-practices) -π This FastAPI-based project and its documentation represent a practical interpretation of Clean Architecture and -Command Query Responsibility Segregation (CQRS) principles with elements of Domain-Driven Design (DDD). -Although it's not meant to serve as a comprehensive reference or a strict application of these methodologies, the -project demonstrates how their core ideas can be effectively put into practice in Python. -If they're new to you, refer to the [Useful Resources](#useful-resources) section. +Stay tuned. Refactor in progress, see [`legacy-2025`](https://github.com/ivan-borovets/fastapi-clean-example/tree/legacy-2025) branch for architecture docs -# Table of contents +TODO: +- [ ] Polish code where possible +- [ ] Write integration tests, finally +- [ ] Explain code and patterns in new README +- [ ] Make template project -1. [Overview](#overview) -2. [Architecture Principles](#architecture-principles) - 1. [Introduction](#introduction) - 2. [Layered Approach](#layered-approach) - 3. [Dependency Rule](#dependency-rule) - 1. [Note on Adapters](#note-on-adapters) - 4. [Layered Approach Continued](#layered-approach-continued) - 5. [Dependency Inversion](#dependency-inversion) - 6. [Dependency Injection](#dependency-injection) - 7. [CQRS](#cqrs) -3. [Project](#project) - 1. [Dependency Graphs](#dependency-graphs) - 2. [Structure](#structure) - 3. [Technology Stack](#technology-stack) - 4. [API](#api) - 1. [General](#general) - 2. [Account](#account-apiv1account) - 3. [Users](#users-apiv1users) - 5. [Configuration](#configuration) - 1. [Files](#files) - 2. [Flow](#flow) - 3. [Local Environment](#local-environment) - 4. [Other Environments](#other-environments-devprod) - 5. [Adding New Environments](#adding-new-environments) -4. [Useful Resources](#useful-resources) -5. [Support the Project](#-support-the-project) -6. [Acknowledgements](#acknowledgements) - -# Architecture Principles - -## Introduction - -This repository may be helpful for those seeking backend implementation in Python that is both framework-agnostic -and storage-agnostic (unlike Django). -Such flexibility can be achieved by using a web framework that doesn't impose strict software design (like FastAPI) and -applying a layered architecture patterned after the one proposed by Robert Martin, which we'll explore further. - -The original explanation of Clean Architecture concepts can be -found [here](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). -If you're still wondering why Clean Architecture matters, read the article β it only takes about 5 minutes. -In essence, itβs about making your application independent of external systems and highly testable. - -
-
-
Figure 1: Robert Martin's Clean Architecture Diagram
-
- Figure 2: Revised Interpretation of Clean Architecture
- (diagrammed β original and alternative representation)
-
-
-
-
Figure 3: Basic Dependency Graph
-
-
-
Figure 4: Corrupted Dependency
-
-
-
Figure 5: Correct Dependency
-
-
-
Figure 6: Correct Dependency with DI
-
-
-
Figure 7: Application Controller - Interactor
-
-
-
Figure 8: Application Interactor
-
-
-
Figure 9: Application Interactor - Adapter
-
-
-
Figure 10: Domain - Adapter
-
-
-
Figure 11: Infrastructure Controller - Handler
-
-
-
Figure 12: Infrastructure Handler
-
-
-
Figure 13: Identity Provider
-
-
-
Figure 14: Handlers
-
-
-
Figure 15: Configuration flow
-
Here, the arrows represent usage flow, not dependencies.
-
+lBqcwEHEWhsd%a732d*(cv#?hGL!lEbJ$D@NIr+6ityO^yqeVqxMOLkQ zu-Mg1%M>qa`i*JsJwCsKW>G^0Z3YDsgYdXUM8(L?UfEsONrNNxsb>P*J_oN2+8yr{ zf?D+V!!2i*H?LQ9so}kq8Z`=}du)AnF*HwXuL|=Xx*I0UH-|d35XlymO1LnYnec3! zTh4qdJo(#Wt1FKg8~JU_);ZP_WS?Ue%9g;iVg|aItjrv|WfIJf7CK>_+t|#2xzC*O{2k2TYF?ES4;Nkd3p^ zaUXnupLV@&X6^}g)_o`{PXg}`Z#RV#-(oLs ADb9daFqOxe_Jyv}ji3$&Z7 zZ{ B6$SIe zXCNZdI3gYrPd13(226?bh}ro@*&5DwH(f(uLLr{|twuEAA zIj2=#+$)((m}9zF@kpIeY9^L7-xPefNgomzo5Nfyn?rnTdN43I)jU#fe`j9kFpi}Q zW3VWpdwV`eTBA|=b_b&Q_3)zyBK&B-PFKNT#*G}$Gs0G+`{i~WNCz`hr4JTu33R5Z zvtP91VotAHebAgQ%Q;6+aCsUUl_U(MDGrP0 EAzNfoBHb2rT0K8C23JKm47~BTw~&g$2}vq$a*UmWg&U^N>sI1A}W&BLl%5 z)tiki*u;a0<@fdV?G49isH) @MwX--^db; z+%G*M@iVUa%9MDrwmwp}h_mhT%2+$}GEQlSYt&d;q3sM}EzEwvuqs$ZyZGj(j1#7U zDIYre4YukLRslxOe*yR1m#FwOH&J<{i3S+FbI=12>kvcH`Fb}rbg|t20mBmk;PJr< zCYcg>1o7ZUz2u5DLTvzIMcgF?`v*vxfCLAY9?dr}%ZgHP)f&gca*p8%hVj gHl_w(lIMWx2#u5 zn4PLam6w(xgl#N@6Rd0MzFIk?efalZT?6E$=_{zQ(JDX57Qpd$`)2{N^df+H)$hGp zm~a@sydZzGqrLgQ#}XS)Ic&%5bLeAs0pqDqH<4V^pVUw!O(_AAkPpUKcw?Nnb0vl} z2OCP!n!M3_dlC{PnJWiC-Nd8p>~n9Dej$!gA&Y7ADcCACIQmh18QcLPK?zQ!aE)I) zYS-61Z~j3v=*YIu5-X|wTDAT|^&^(JT2hpul!-#+C1ddPg%GG`s0w*Kq3Lsa^nUR+ z1i1u)6yy)2mnr?|*Kqo7VRRU=0qpcgorQ|LB+u_NXhH^xJ6Lmt2~;svaC9+|E0y5q zbwxS1s)VkTgOifJF0umkqf3L$;PGHgBJ>3saF;>0VcIVReBaC{0&^2Ed7y|wTX*yf zIgex`uAwn!aU~)93~_tv0Vy2BJ(uUS|Mg0w%lujX-;`%76+ndQ~`FHS7Wp2|U=5 zQ7OgH%Xf2jqRAR_ClVfrD7a~`Oe{5unbXcJC_tiG4?|$nA?9b*Lmj*6*w W~! zbx^XfX9LVm=k+Zg;_9J3@GB#- h72APA7cLaE7 z3Tl2c!EU;i!9A5_HCq&^fsZrR|D@SA#$yrqKKANh4_gp3fB2IJ?y&YMbaS~!jYw8Y zfO?u&Xp4<)SZL33)Eo^cAtsE9r9u_Ov+!N3=4X0*kCl)ezr05+Saq}aFpTs=>07G< zEvu47q km++Ta_Z_gwwF@B+lCd9-Y9uy%Mw zs$dmS8#)lfjZOB*RpzgWd}$s;aDhYhl_kJ6q(`ydU1V-%{CZ)eO>(o*FQTzN4-xxW za2v?X$Y) R`j_?peEdJbA&$Ix@%G$+kW8`-f!$$l5fZ5n^| zm-u*!Q83wm ZDgNp0-!vfJEViG%%a(vYfGH3C^%_-#-!LH(eu#iJ^ z;;KRhw3@Dw^{%2x_}7Hb?DFO&pc1AS5oZ1(bogcET+>3wKx6N5XG#R#x06K+TA6xY z)54y?-e@ZYF|%9pXKTpcu!>yZQx^K_vyjrD;GkdM;)0RD-zUzYDtc>r1Q;qiF2O(= zJZ(B1&pxJU5{*RX=Br*Jz%ugGL*nq)N28@JhucFL7CmbvfTuxV_L}#%Ct*whPP7Pk zQ0R@<=)lc!W?^i}A+e;F50OVJ^UP`Ymh3<9h}^-+K-HcU%~T8?ly+S|w87!&pICj` zCmL$Gc}NlU8@~eE?S%OL1%ZKqU!w6lhT%vy6}wB=D6aeVHug6aW^hEf@BXAekXaK0 zlfZtPZA mO2+a>x1CR>$?|=<6A#7>%&;qiyrq%xwRKa~HLW0i! 9eIISfk-iOk@7U0&rc5xkKet-sl zX(1R)Ex6=%v2I@(##s0Qb%i5|6h57+Pvaz7iUo1TNxCDcohkP+h0%#r_8E99T!UQK z=2-ZvY^_;dOC!xT??&%(Fd$kJ*(dp4gi(Y{Zr49xZS{qzShtc-oY@Yg<=Z8RDWPx6 zG^l*p09xi-5##sjVh!6fY B0+=KB)$joWy+eNTyv=s!NI2Dc!2awcr zRU8P I);r+$voZvs7Q;yj?evdhk}5pWRqqZf0UV|!0r z3+pX%__7T%p~CAqlBfU}#1)qD5?;~?avB7wM%F5j2s1H}fE`42hD&U!U zGDXqB-mwbQMTlxpWVAnVI_q~EUQZ)n&!yiexOSN-MQ)zcaTO^So|Ue6*CpRZo;9!P zqubUI!?A&Nayj?*@vlI+s)FA;#eUXTz;j|?Wp`EH*`2LkQ98u%BevjQ?)P$srqx1! zj}(De*dXVUXodIN2m2V?v_EaLH9+Es|5Jr{58gQ@1WB0BL9~+m=<0Zn*4)`~j%d&< zR=GBN!FyrX1&C4ea8taxn;QzhT-ZS|m}rR`X+l=0Q&_Zg4U^(b%p2&FhV!g;b}~gP z fMTsMV6cYO6PD1L%0?_2_tnrne`9T>{J-!ahi !-!v_YW8)|A}W`7>p$i+i1tyOEOo z{$VE+k=wM%YHqWh1f#r(;#tnox9igiWC$I3-VQ4IuwGiDB-na-h96h4GVbmWx_BM6 zdoSFV^~JG)B%s>c%@-n{kQ=&N{x*dTcXUTcLiBL HilZjGwd9?^* z8neI1MfOC~du7Xmd4SaO*F*w8agb%l{~Vvr%`f|9tZ`yx7A_U8#7V<-*320)!DMuK zMcT44(|EAr46dfLmi6N}rB0zn_f2#T)Lq4Y7O7~*e;0}$`0^S-fapD$)}d>)rTKWG zurwFe>DyH&J;f{L%SlkKP$_`e#SfLdZS4(@|+HEcI=%4v$znIr4pOQS3R7r)5a6 zX^@qf^kOu4*>A OO_nuYlYG`W-*MMNc+mb>$JwND~OLrepo&Y3zI$h*z z!uZ6&<@T5TI>=qwiB)7JRHh_~8`xz&8E7)Ta(DEq^5>G-_w*VfOMhIyBiqWVhm|fj zG$N~pNB1ehXRi=vc{T52;o}T{_>pZ>PtlKqn}$ @hCiroP|# z2%65W-A~G6^tE+4V~3OR`!N0RtoutQmHA*+$ye)oZXI!;U%VqYNG&FWW_u1BN0>02@7=exza zn{b4e+2pP{d)jCFg}b_ g@5AA&Dh#&WA9hhWj;_ON@hg>dfjyp{A!~DlRv3GEYu0&rS3_}GzzHKd zWQwjw<;2^B>?d=1YQLHNvc0Zv=|)Z0-kWs8=23v{+_k3@H|_zU)Z_FVo4ic3ymVCI z+nbBUoFe%C4_c_XwTB0p9>KEMW9d|jk3UxMTkn^QOCA|hS{bRO(ILi8ID*Njui>h| ziuubuo*`K0OJY1W$E!Q)?E>)>Z!kNHUN2!e=P3)GM%uYnerDSrJAnlt fjOiQF z1iNRs?2=52>3u!5??%4d>d7UoqBljwtL>4D?Td$89Xmf~P~WIUY}gVg4~I_PS-JS{ zuIN&Uj4tsT8>6juvy<~?q4cWEfsMhQ)T;?C$PtUAHHt%zb~XKd?CoE=v_(yq$kacC zw8ukl!;J%PlPKe<>uk4 +n@w1sX@xu;f(rvZh?g`LpFv}47T4^+RtF|w@ z4bHf8e~Kt-hciVvdn;JvjHF>}ZE|$qpVzUf(? 6PwZBOCd+sc4O*hf} zrL>sxz?;Qj|Hht$Y#qt+H%Hdu_BvViF#4MnCN83%R>TnZ6*xXz^cxH=x~8lzxuw52 zVUfeMm+)u=Q7#VmuPmDZ?ZZl<166;!A=KXi>~CrXtl@5*3>cBX74RK6gwhh0d!#UK ziZv?pHA&m3EN8P}ZDAXdY-*{m48?Pu9H)6gmBbu&OJr(SdyW3ag5&Yd==9^&ZsM#1 zch&aJY35zum2!+z?9Zo4E00o7$zZ{%6r41wL36EQGf-#=?+?4^y!m~>b87vVk{nsF zsaJ$KYZX0K@*C^DhCMSPZJhHjfsE;Kk^$662I5)nGqPBc&a-n3PPfD|-k(Xpdk=<| zOWIjUr}>To8H|0Ik|QEo7DbHYHFal?BW&<~Dsb9b2=y44A)NSO7GI;Th6pBRZbCF& zx#rHb%2zSK$K=)<-jMf(+N==n$1%(2)Ua0<`KOvh1EWAKtc6M1RbHVXMxjC09L4Sg ztJ<=$Ij9T0e4~4k)$MV(`@MWdBRgyui(| z#?wi?|2g2*tYJO(#1&JOG>$^CLOdFcmt&eYQ)@?P=n!mI$)B8nCQQE}#Kr20ZJ-@2 zq+eOoOS$fBY4x^O7HgsCTJ?0PKYh%kjP;-<8C=ecj$;$ =yQ8A!_8T$WJG>}lZ>Uv){gFPB#%<18O^K)qUgp?7$8uO3>80t7$T-QP+6D?N)* zV3$;hLGMzcU z ThfyHd1O&=X#ePlH+)-I}b9s{)gU?#?U14#Wrs{ zNVB9s4?~)faL<0V`MX6@&z1JT ju_aqyc)oKIUN9T`BmRv12?xkT3%!K9q z{AD!YKZ!6?tvM-~$xRnpSHvn91UI8;Djy3pyEH|OtIcV)Z%S=kfXdNxMrw$KDmYnl z9Sill1;QmIttGfuZXF2AimX<#`WoO}lxuN&k{zslOimF~pZ7&C{N z+gt~6EYgYMoC>D(Hu5{; !d{1lHkf(2Vro#04*UD?J2vdZhO{N7eg};A!h@t0m zZKBrhrxXf)*=M=~7nvJfubVo!yL>$q3Xh!JA~$`k{IX05GBBKkQAMtYyJKq83U *vzeB|nMr7xmw p4raJ;|RD=Vvm z6~l9wXYNRqkdlj+)NotbiADLO-r!=TcbzkDhT(j~b9Q>*bZyd;YEYPJK_0D-e)*;1 z>~9l_@r_CYLA@(^Cp+7eAP1Ab;oM!cq>i|{DyO+NC9g)e9o4eXT;4J!9^qBko0Zv2 zGr?5+$HW4x7gW9gNj+f^vQYZ(EbucZaToW~Q#~L)(j`(la>0fBP(1_x!J({!Y^883 zKCTiPp5Zk!yRcMCG=1YnniXNQ)9SaL`8O25I-eZV+nY29QJwIGRPVQW>%-q^Hn2U4 zUbk(il=wSqI?N?n=GdS=Yz!(7H7%io(DEj{3ESl!U$lgc^n?h8NGRy(3 6dqV} z2SVM{^SlrfvduOoy&&e_lQ{3_DVSP&1oF0Coa0=Bri3}a@;q%{wOP>dLEAOLx!C;l z{GL(~PInJ2xzV+0{>GTqq n-%^=CPGbM>}zi&HC&`(@HduvOREq%aFa2I2dmwA9>p6&&5@#4bEx(!w~ znWmNnrkD%osym)BT=d=zs(xg=$X05 pL6`koG%vC-~k0KDS9AqoG85;L2uYZ4rb#qxBOUvJK zWa#f%(r0?6p=i1WCqVVF+^92o4PrXI@*Yl4erhw?TKrC%j&$D}PbBvPY_E@znv2oX z?2k{Zx;{h0J>3@mb+(+>{ LF zExFJdnp5GMYeS&_yIZ)Qwcw;D)pZULmk_-xpq*U0F0P$nuYnOG3EQV;S>uAbwN!?= zE&1+bUZksS3GOQgfP8{ic$lVhVU)Awo1C13{Ag{ARCI=>)| 6+5 ~a&iF@8(M5-hyRxeQ6ikDf@I zl~L%1V>LZV{hw_n)k!;~!GDm!sStlq8~eyY8D~ZhbiAn44g qAZmd!2#xLJQZ zD!?0sdKQ~=8KoR$8N&&8B0p;M>?T!y>M8GkY%dC)r?EXUX62zwpb7pmp16apf`EXt zACkf?j-~~O$1%7YPV4EuKez$YuIC`0Qxn>z%A~IqB)$J;_FNSjJk}1$@(#_Nl4SGt zt8#ujKvPuxhPPJ7$8L7=aHQ0hYJmgK?E;&dKeOOWg;S|ra1WN>F6if8R57v7OpjK5 z2CF*-YC5X}f8|MY68-WXSiJNT!OFuKf(ZBftgV>maj$iE O6ehV&oH#PAuQ}7;Qy L2ZC zsxXvy^~#VFX+bRi($!!`n(U?8ZOUJ*NkxgazF~R?8@CU>oKfDwTZ t5dvMNOf0|Dt~m_Fc&E`WLm?w;PAqxyT~DmUl#Suzl{S zj{g%F4p%+>z3lZl->?1zH}$od1f>S!E)1g+y$`ka2a1ly&4JskeZ8aj3B42l6aLXR z5p&^t4&U75U2h%`c8X<=FT;Kg(;Nx9_$YGSf%8KL@5AUb>z{e+`AihsBNCVNzQ{(R zX|1G66ect6;WM3U6~IgF48t{JPg-Qnpqnp>YUKbIn_usUkDbtx-ZA?4&AWzMSoOaP zZ9bG4x-C{Z+J_xiyC#DL*s#&;7mZsM)03tDqJPEJNkml}E#jvDL@*D4!!`@z9E>f1 zqyR3a1YW~Tv(OngQ}OPDdF`1i+o0b@5Nvkdvl#Ls@%Frbu2Kxy?#-uUMG6*8bFH7F z1+!|z(?Yb`MYqsqt*);LAa?HkzTcIVc&4Pq`l*J?&(P27u(x|C9P>F#{b0^LD46p* zFUc^YZc)f ^16z-qE^DGP61LrGW_iu4ZfUE|J13UL8`UUd3Yy1}hT$J= zpNw}Cw<;TiyL_G>9e-`_J^o!KvgV1OnQ8)KypO+0T 4hnI)^?uUUBg!>TTX#A`YWzhNY|RV_*6Pzz-}4ZjcG44F3{Vpu z*XOB3eOc$rVH6H#S48J(OFyZPovNFvq}5(z txq lR=)Q`W!3 cn6`xA2z&Iap#?i0Y#{K$L zIQZ3gVxH#3q>fQ!qw3I=N~@#?$t~4*y^b+e-aTzsw3e{5hU>9EI|QT0yO@V6mqC}X z7%9iQzpOLm*da;x!Hb 1Bo=<130Xcf%PfCZE)=J zYa}(0@Km#WZ$iOQ*`ec7h=WkY^V`F;G9!jR+)btN&F59;ntPxH(<4_38 d(rUGeT zpp(mVW%@@8qgy5Cs^*2Fd~W6?m@D<(x;P!CtKT?N-ub#7Fwwe78H)y$O4=hknT8_8HjcoU z_z# #rysTwR }y9#`nv*t-cq>D=J{+SaV5wV991AoWz7JHI08ZWS+t7a0#QBA~g283@> zw$&jt$;G+M#y8HjFaE-B)I7w@o;b5MnwB%^fzaMK!zZ-yVtCp+d0vEuiu#f3SOpjv zuaExeGG)!v$l24pU1*jFsbQVo%8X_0s8xb9jJS#?Z2QWuK*tHQ8X5%*rI(#IEk59> z60YXV*(sj_0FCYa8OkGh8B9*NM*oLM<_))!!EpQVD8ZINbH=SKY7YKvMvnKg7ag!- zOE8X=Vo{r31xX7eIpNXZ54lzj1}hoAyk@Z2>Bfm%yra5;DpDZP(soIz7N3dtwW$K0 zN29ZzOh0~3%u3>aNxCx4lKX2H({*tp9(&i}iWsO`a2JCT$S~wA6uJ8zu}1b|Vhil1 z1g8l`=e3FO>yuv7I5v(`n`Ujo=GP;TItvkv%U(~R`QZ>!A{L6Qh$8a=al4V&ts-R< z6*ILY20Hq0V47^-j_PHFPG1_c?4WL!+{s`rZlcbQppjW1Z3e7;*~|!20m#8r5$!>d zxcNUv=G{4tQ6get=5vg{5;FJYJK8~GU# QGsE%GVHOZ2`taM3W@t98{R;CWo`}M zbsl*Yv@~paQc)~^w?jw1#fC1MS=9zka`8L$zI)6*itT4jW0p5c6amEaW!ooJ0+d*^ z*e;LJ=m4RPjK*O2&1+^a zfTO-;qL_I21dF2bxRh<(QnJB`bZ}FxDTu>sXUlAQSLHez-IqXzwsAk~gmMpNh*p9D zSvi})e@3lTY9yIJGD&Kk97666k{2*UFfSXSyAoD%yzLW$N1sevJea2x)2utwp^h&t zC|9lnD$|?H2abbonGe~p^m+RLbqi5^b80!yxhv0>808TfWX)dwU~7THBY}JsoD$AF z;zbaLV>Te`(&cP0H2#R)bt_A)agcVs7HTQOc3olr*BD3^VJ;D|4U2tx@k;l_3sL{G z6+I}VpBh>R78>^$*q&WfBS2d)Ul)GbT#ip^SSsJ1YOdXD9XC>sOz(av!9s!CM5(NG z_56%on~$6QHTmcHo=2c{_;nzJuUpHZb@cS_l-hmpz3CWz=Q4H`+C!*X?NaID>>A|h z?W3I;y-7BB>DQg*O*`qlLqq85GJo{0frXTo4`IMr6SN~o@$?w;WPtBN!h-f~)*E~s zHtAL73_d<}mv- AgEiZx(wW?AQ9+kU|+?@!1+1j8PakP^(GlXn3eOL~yC9}kFE zi1EO8Im?N^)13eB5=$8q=v~Y8Y+-k$)cG~~ VWC96IUS=zX4@=G%h$q3*f2 zXRta{dMI+hERS2f6;gv*!P+}P)zQ5&Ccnk)_}=M54MN2KVd)zj>+FBGYuh+0Pu aj z7!BLn3lJH~h*l8>R)8jgvwtOC>@`>qBp8=yTxc^UQUaPF1 yKSW{XIoiAa2KP)_}`;RZi3RcU~3^wFInE3@fw+*iYgKerpAivdHoyC{*7 z@kijCW%^%crS#W89Y3rc2t+vN5ywsG=GgIiR7IC*5KjWYNQb!{b4Fw-zfxnWylHtw z{89P3SjuHGB~wv(U$(Wtj!#kz|7WQkIyxYIhz*YAu>ETXgUjgu;RtY{S5f|ea^jG@ z>xNu};~9i&zX=ZeW-14P(K>m?I*7|CTDPmW3#;!AA=Cp@5MGWOb5U4`W*K7*3VdxY zq`hcbD#C}oX1cUmIaZko7kKgec)q4j=GVf}cP@#qQA@c~E%Kv{G2?Y|<@9=lJEv>R zWUH8Ht7`LQa76L2v;%5?KA0ABgW?c;`eC_hvJd*@Q(5$~bI$ TD?nfgjH7yNb z)u}^f%9hFYc43f>g*Qaew!bR7Mb?{HLKhv%kGp|ctsW%g9H(xNZv?7eRhDm7#lKEm z7Yaw(0EG+ZiF6SH7p7+cZCd=LGGYaK#^4IcPc2zn*&uHwUNey4d0m1l@l{c(`YKq6 z?-h*XX}^7Y%A7?D>>7&oMZr{C2^0<8>wc?6i+y{Hy>tt{ 8#m{rKY2JgYhq6hcu{}BDT`fF>dMRB@0Ch#o?kyo=d zQd7N$2#gu76%7edpH7@ni&K|=IEsSRIrD$`fU|7Ia1`!`h-*fVkd1VLx(qNqwU=v0 zq{!>eKS|JxtMqUPX{sd#CNLrpR?U=}1Qq<(sv8@$w-L@JYRmO(>F+Ox)-X80=oMBg znEj{?GaZ7YSg+95@XI=^9L5;6<*y?TFSiTPa!ys+O^qfIV%9`VU2%6&N2FSdqq=jk zzAm??*zTGWRlWeKk?@nFiklxPJ%nTjP=6tQv5G>m_&XrgJTr}e)q2EJ5VICbd=MeJ zlOQEi)QcRz+^?B(DXZ6d|72Jq^0;I9qqJd5ra_UW4rQh#MlhwTYC{t E5Tvf`K1>ap8DZ6_z4A5Qhn!Qm97@^Gua_*6S<6IvT{ z>7m>e$Z0rMCgDr<=3D-K2$?Gm`ueTNxQ1O+pY*ddKlc!T>a*hy@OV=< *Fht5O!B+^o00gkIm?!w*H)jW4D(p~+|Z%Yc2=l6 z>KLK{M j4`$RN zu2T0ITU6CNIRXQnj*M8U!KtY_q?wjj*(h(8etmx;)%Fg!?mCJ~W|DlqiK6PiBOa^M zZz~+$A3t3e#@>^bPritD=_&S9En496PNA+&(NEcOkbVqJ>-r{y@sF#R>jw!oEggv# z#}69f&y~@*uIjqWkXf)v3qzg^I9@3`X(+mxhk%sNQ>%aN8|aFngOEBJ7Dgv0iNVqA z$!*WLR4xwDHK@KhSiAyMHj5l 2^$S8sNr_(^DV3#<5$?M;2L^7+=nl9tnpc?B#|Gq`sB3BiQP zf*`&(Gn+@YXdRghf01MO;$B{LZ&b{JI# *Yxx?9Uxjz@k|R8N7W^mH zlgf4eH&RJJ$c$TS<&0s$}<#k(M^$bR=qoNN{LzvNLg0)@f2M5+9P1)PBy9zes)C zR6?bkKKS`?qLahAS$-no3xlThd<1Se8H$)Q8n!Epb-RlG2Qf9)@8y{n6pwsYX*g)e zN+UM)dUmx=iCef2fommZLjp+{Poq0QnzckyT&LP6+W!vUYTsMhcaz2mQ5Ip49`ZBe z;|TuI`t&!6Cuy`>&Ga=RE*KwC{wOgtI#?cYwGQJ~ZK&Cetg4i3Kq>Z3dY``182KQ% zN_=*4EzfOok*OYO*j%&p {A zd$>UKRP7nn$!&j(e!~5zY2IbStFuwbe0rQ=F)rDcj2*R?TWL5q?_3aAe?L`>v2LRJ zQCg%$Wz#&x*W|2RcOW8W70!fxV*3w4U!RKrsr1N+^6D2lWcKWo?$k;3-<6e=aPIm> zj%6ZS^9qV_upkxxKhokU$zbnMpn$jjo}#Ikm2mr54~B1#j#2tT3c;%6(n#d8ddjmv zy (N>~1@!Q|TaqT+WyG8Q{#YWl6qYJtR;VU43A>zh3l{p{wI*Yi2# zt0Y`o>Wy}W57r6T2_N`wWF{;U%jG36`?rii%_9m1Kr)WtozyL_)ewBit5;Zgc-=uL z=B8ZTlzeo00uDb7zss=1Th>%$?zq?6o4KkL{@ t$*de! zWXVq~4GVk~mFFHFB{dme?ftCL#nbZ>NW#HSyW0dkqwiLb>tnqS-Dq#Bw?ctT^8po7 z;62}MKL~&A=I9VVEt=PMxiNWW=l(*c-qBJFJwLfl{`_~I4Vwj2Z@%`NpQcR75*J5f z&I>E^5<^dldi4^x=Ep_{BEXg5xzW nkeJvm+0oMwp@PbUss zdL6CnY%H)gQu6_~?s#={7JmNR`FkPokLPIJk^JK9^xth@`;%V#_D=2Z>sfVQ-|LEY z#dVgaa$e`orAPSB&!jBHJ*x|q3Q4&qFi-y-MYA!kmnx}|wZK0o$$ tTK{Qe zRQ4K4O%Q@40SW$?bS4;xIqv&cs9xR=#J)+fZ(vZ^yLc1Q@jU@R(*+p;)U~If?#$2I z?Eu)Gp5y!peUJSPLBaP8ziQUZc(( lk!U&Sb(j;A$ocM5G z>SVmh!8_q!XZ>5%Ugr{Zm%Va~@8=!vv3gJM +M_v5x{r ESVwc+%cH}6)obmnscKGO$8-usW_Jj3Hl@oWU8%+P^)}CzL zNONTl9n$62yDctjQv|#|8enVPFa0z1s&(QpM70=n l@*cP}@B|vOy<|Gq`@{IQLzgoCI8tmyyQQh28v&o#s*H!kY{()EhotN( z%Z*gL-N%vWPyF5m45MKNKW9!G(OOPB$CGu5rTN>FR!l{XSP;eCLIdw}@2ETzXF!NK zW!%usczHf1P1Z#m`%pU61I0 ;BEriM}cv4LeJEZr~uShw`OX755&=cB6tPbh*F>)2T=jR<{ zEj K!mI!D^up>mm5<+n9@ZKU6*Y2`oD8;lk*Wi1d-j8z3ZdX1sLPYy!jJVNn!wW$ zo~1G)$P|*5rmoU~l2DQImZo0wKE|{yC#{|{SA~T?O(lEcC@F|O2wFU);fFBVE6c7H ztsR>UL1O35d#Jdtpl&V6K5KuP$w(n^QdC+~7>7xTA~M*dfhv2(9I*}=fMs)%hY|R_ z@$sVPk^OIXWdFBcFz>LEVw$yBvch-Nw?h5Ut!{EGNC+iecmX=)quf7|ByPin=U6CD zC7?wW6K1})BUM8)D4}2({jJCH8t10$%oB!jXaMdWdr;sL#t(G&)b#p0ujdTq*L3KX zbqijEP6v+29Od2J54T5|>(6UlE#D>l+pZ{bzN*_NP!d@&DU(z}ViQ+fhdI)fvKvS+2ik;Agi6E#y+nFT!U#vUALGwG z4OpIc_gQ{qaZn@&6Z-<-Xe1<)M?nE+PcCA-1oYK+Mi-S>s(W){OLgbaH7X6kVihC@ zp3s1vF@vx%UQ(oxG^TWtT3FHR0;Iu&apUR^dKB1@()TXFMe=|_sE4>OxUmTWit0>> z-E3kS9<_<*0vEG+Ee7nGB7U)8Dx|{c& -S++8Ma($lVp%B z?L1vaC=k^^$(L+4vww1ri=Jgx28?(tU(HJhVPFTwp&*KNsRU7`C~jX8OTTveRCWlV z$p}+d3PFJprFt<@=FudnDQU7KC`ZkU3mZS--6Uc~ijq!Bh6VNwhk~s #ej6@b7wPVGXf!QnY1jgP!^a$)4 zkm-_)5~agd;R4iZCON7wWS%6?b61td1?VIOp`w@-K)z_{!QYn_c#|FSmewP=fh$1g zp-*#Dz_O&5kpSx&6k2dAoe|O)HW)o`M6kfq!0_2A_N<5;l0bZ!0T=d3S!6)Z#&W&h zV-q(ic+}C&{G?LBy|amDRU@zo3DLVzd}&hmGyAPFjI(Gr`*N`b2Ou(E)_{dZkr>9A z!NIKq0&LlnlmjUd;j|d6sxTMww)Xb-wtVR0Q3nw$BmNaN4jQB)?6xcNlWPN7q@M&~ zVgVdud6D))F-UNm!gHJ?hE#)=DEMHXY+2UA5l`WU$GKdEmOQAbv*c7E#iL<>U`lli z_>8fb93u1)rrnFg-83R9+1~8gR#>25iJ|nmG6hqSn5qb0^o@L#AM}&}4QI@k7gqc~ zFAR!L-qjBZ4f9*U6wAx _W-AdCpU;Teh!Ddi=Wupe>9NVR!xH~^h=)}E@tDy50v(s37k8fdMw_j4>_4{ z7o&%VX;z{JCfa-0ICT-0hXx@*(Ye!eCJyfBJE06n$e i8i3a5o`8|mNIOxQ(0U7%|>(~300)Zi%58Q4XCz9x~)ve%(^@;uX+Oh{N z%ZRAe2B#qE|Lt(Wo(9~jT*?7e%M+bjYRpVZ*|^+KRUNDzZq`VpL})jYCc*l<@Wg<7 zL=in!mb`%Ed2_<)1bCO}4w5RDrQBp`Q4|~r87&LW;dHpcw0+4>=}GDp0G=JU?Cnn= zxPnlSC#|d&m!#Yg3qO~&ERL)&L_)uM5duEb&TNYvUHmUc=&UgXg>64CaX*+SDxeA` z9Gq;BONt3SGPLbzJ7C?$$(x2uS6Ks$MHYg5Tm%o%%`DhT0iD^KGH@yxJWlOarbXYM zShdal$6#<2&;%+=7rF-79KNrtAVI`cJ9a)-`mjFKfH5)d {Z1Tw>PWnzOb0-- (C3iLTIl>V=}{zbT+WbjaY2Vr8h#yO(*C$RVFs$fMIte? zkuF&NFd*9aF^p98dK7kL>?kHf>O_d(TIK@$4yD^VQ6{H4#b-nXylx2#bh?d$*%hG` z#1E;uWF!~1NYRe%CVwg*5;nGj+lBqT-a{OZ4A(NDc^pX>**HWVye?u08%sJAn>3}O zndk(hML5?F9irF(rG%j-VgOReJtXhQ54vq=ksu-9EX%PQ*Z_S#q{8^qVDI?hETNd3 z7l8n*7LAl0Y(g)lpEv#wp@{JVyNX1t9I%jrxZ2rk$aueO7@r|8M*JT*zc`BTnglSL zOax+|P?XU#HAvCmeR1>Jw;y**Ov 7T^}}qsX6Bg&**% z %;{r@D_wbUA~MH_0myc07!vR|2FF$;RD~o}Mbb^^gs_gICm3sq z&jZ_yFS^88L`np@aJvBkm>c~OBoy`|ZlU#K30#m?1hv%2Ss8 lIjGETeQ_lc+MRg$l59M>l;f43riM9m9IX0NX0pg@BCe4hp;x$=fpw zG?_h;ER7F!3?hmto>Ya%V|o=?7~~9EfH4P0wD`n%y#{tMLb>h~!rc@-?Q(zx+%y4X z`@Rj=XK1Zavu4RXQTT0)J=N-;j1ArJivRZeGLGv18OIW~Qy$htmJ%rM;B$Nr1R|6& z<#M8pJK-IboaOL4plD9`<7(gG_HH`j&eRKxOBjccz=yLf=f%%844fQ~L5m|1hcIpv z;S&NOTEJTr;MI|sLxVK3I EvuK1lUULI#F5lIz zCnsv+&vTzO!9qrp4?!ZFJVB$-3GNHCr2)KvE!6jLT&X6f@25Iq_A~bpAl1}gCu~a6 zopXsZ@!YFY-lq+`DqT1o{50Bymnls^jRT3)@CvOz+CgUiW>8sR7-WPb{3dp-+&?a{ zl@T%Y2;6_7H9~ZIaRFdXfespbM7$oL)$@am@JC`k&U0)jPnN$-qh@H(^ZXG3bpcdJ znovi6i{i5K^FbUQfBaD60XXwWB`nPIHvBaB0?UOQ7WO@cR_e{T6r(f;pdkhNRq5yO zn!OSGc@CB}_#0l89-6DW`^`C@%H1k$$ZRks>vyBPbPk^z=k*XP%5BX+C&4bjs&T$4 z+VpD?7PSYlYEc2t2~pEf8k4(ca35MCYHFTj=;%IY429|6Uo1#-Tfe-zwa8O5e2rq^ zEe+;Q;WhB9Xv{Fnhw;o~N6_UcFckuLxpqPj5U>)$)Jq&eaVuKo%2Y1=objP$DQ)2T zMcTmp`GceZH0cIXKlS~5_#zR`5>4e*7#ERPsZEvYY#EGwfQmSV$WUcTnz{&-+N)7| zA6*B|F*bI244y3%$*N6I5X-%4P}Vh)ux#m17*VVfxM*cOGTB$0Zj}H6aXhkU3Bxd5 ztae5zJ|HuI$`uj`Vtqhn>j!oS95jYHQ+G%QbA*YIrEqdawuoT`Jx#O<^f_{%w23V+ z`deB L mW#4MFmqEVa<3MRTf12? zoD`_2uw>dB$raYuwuHcfRzGU);v_#AT|~QeGGYS=DHKJt{319NylaReI)g!|ns1E6 zF)Bth4i!m1#X58Xoc7Q}YdM3bDl_yf$>-)vF}R>%FC(&~?GaG;q>awMHul3pdKE3x zn`lztsl-R-!0?k%0Nc GTK z+!hY%?r&qQIAkrJTnunq&pwE&3`cA-21)g=FY0i>*L@kf&X=Ko$=;*^pRQl&q}sR- z?L#$~bTEEWh-`tX#1m8(=tYnljtyYVk} WH+rOj%M57SkMM+}r zrD!mG$O? cY)crYx5aUn=a`-#Q35-bfUEf7Yi@>MkQ7Dd(tDb}P7 z;F3EO!CJ-*;C|}%GO(eEMtO`OsO&}9L^&Mcu*WUN#3bT-qjQtM@{SePO}e7|G;Q;q zQ>$bWL*~;ZPx&E}h?HD_P30Pa0ppBP4iwuUtgtPVigTm9cd6tmyTsQg*+le6upCTW zbfJCfMK@9IX2)_&`~e|qTBX!w+NQYb1f~9GmZ%SFpVdkNK3j3HFpw0uD=&=K4@S_Z z%Eh@W)UPMJ&b>@072AJuw@r@4Uw#)d4=;@Qo~Lq%5)?}UZ#qyehbL+^6qXi{xUSqa zJWLN^6dt4o{JymNox$Ac-dQ=8q sFq(lNy@Oa1aWT3YB5dyD{LkNqGufE?~}rb!2CXj18M zW38qA7_2x=rPK;riCQUhX&MrmYFtp6unLXv0g@MpK?IMR>_#ikU#yCz-77*v- i_`d%O3HlOk%Y7V=+HTtrM$hSn=S!|3b}p-c|@A;AbCcC*d!Hy**G!9nQWA4 zIQ<{U_(w70*i`Nu2QjOhSDqXgVVrv;QgjSaJu=dQq80cwj24n -*>^r7BnsEKEZT1U K0U11^Ej(=;Oh^$eZfQ5^m639lSf4;jnNg+(i{aQOs*0%-L~40t zSBCX1ie0+2!zE0?2^XD1h0$+bG2CCRQ0jxu=^yA3gfi0R3bmFWWfq|(JmnQX6J{+@ zsfaCKOE93)SSa(iW4X>UEXd7tcE xSg=%}YMXL; zY(JWXXtL@Fx9SzTwc@ySkVst6wpJVRtXvAv22+86iWDLE)LP|>vd^V )&V4OZ)>(Jk`Crd~D< zh7`Hv(Q&oq-URfN$4hGSeKIeglG<62Qf^bjkZEuqlHqutRK=rspSmERQ4_6io=qzo z=GR%DuZes-)DH|XC1tq)1dIx)pdJbYW?`wpU^Nvb6VeJ`p@RrICXKWltq5PDR7rxg zaME6bT;-mW13(R@{xVB0;w?NOKyt9}S{gzR)DD7zP0mZJ5dRwRi4m0^dr(0^KJ83G z0s*1r)!NyM+a%|}^-I%4o~l?h(#^@l;p?Gv+~C+N;rkTWXJ`%b`wIOE&@4%}6KLC^ z;8>%1wW$l{eyIryPh~QsOB)SEN(nRMn(-h4Zxck*NZx>KanfejEb!wb2TG|h6nLjM zGUa~7EKp>K_5(_{O8RP~($+J(lH-}F5`|I| UG!7mysrD6=OE*J42r HRLB^H4F5TN4S^;YL7=9g4dRhtO?!tNy!Uz-gA?KZBH!a;rMD{W zaF*H^`I$l Jan@qJv2(5WZo$ zi_pc}>D!(A+Kc2BTB58|${^UUYL6Nj%^duTBOoLKx#o3e*ih=BCK(RnC;i}Wk%0h9 z9uEF2gm|u7-ERB)FtteC;uaWjVh;kfx^T7ILZx6yn!dP#7@G+b3V@RYY|@+ywK$)} z9+q(8dE>DPSu3UJMtV}3`ZcZIGZY>fJ49U!rhk4w0$8z*kqv%7S^;Ww-<<650?Ch^ zB>k~^8A2w*+ZMz?Qp&4_q;mxHhm|J0K$4b<$u(uRI7}ak6AmrA+OolR^0)^7W83gT zbJ)lp3TXH-NFfT+UJMcng4iJpdnFQVEeRv#XF@gt=-zNiGUi#Pc#`;8tni?~9Rq4S zqybAdys+Q^ 7Wz{dD zBNu}?Nsn>OI%4&NhFEQj> Pd~;ys;S&m5Z7Tf27~FyxT5s$*Hb)v`+b3ptzlqlU~m(J+`z>p1BhQD5N@f zzg4Dm^1E?l_&r=umRf=)N&XR1LCpBAyD56rS}nge=}~TfivR9Quo_>PXQJzm7-UhJ z=1Y|38xl5Q*yPVJRSJ N3cpuvT?M;tC;M#?B9Q?7 zVITxNxVYGU3ZF+v#P!03?wkxKUJJ(Ib31}7=%7Khsc2Qq@sKs4kN1_yf$kJq=mwFm zagYTuDdp0<(Ej_}r?@1+(hBW;qAW1sz9f^ZdSMxrEmR?}lD#kl5EG>shLIeJGF5 (h%08#ok)Wo2XW|I z=ak4FGj^Ttl#wf1JvJQ9nQ|K@7A%?75{W|~Ho^}1x*%saXdFsiW*tLCF@u@w(lIOc zwQqTpncq@0sLc1=lqwfY_tsrD_v|{d0eR_LXNdkCx f0Pjxl0#!?)`mex@`2! zmpCzEh03SLGHBpzGQc1c5d{Q1VmT=X5oL^sBsoLM1OkG@v8bZ|S}cbBL2&=Q=9Z#H ziYHVvcmw( #nE;Q5^BVoom6bDp)n2fEx z2WYocShRp607Yrcw?8N#h&l+6$RC526M2nUn?ih#65*}lvs0*GY^AvA>FI$`R97b_ zP lJUE@6+YR*LjGqb?V3@nWu)PYbn*dU#Da`J!j}@&rd+VuFgScr;GhQ9Zla> zr(Lv8{t`B2!&qy&rcT3EEa2b2+64B&!3H;iFE{+~Z@Skg+_YilvKBIs*k(7N0b|c2 zd)~da!JX;c(2HF #u^ySSLLupAbt-dO }EF`Du->l5|JZ3pgbDtz7Dst@< zr8mY@)|CGN(JIp^(a}~4(;6l_?M2XNtLCTc{bV;kj9O3>*uf;`KWfbF*dCv#?bspY z+`8%Jz3P1W)E9WE{QZ716Ny8w+i2-L(enLGZ>{jmk`=$RqjL>8grl&2z-k`9^I@^l zgpFX%eyCk&r^6uJo-=ji;IV@r4|Mk`BH)YG7iQu}4iFcaH5$XKz(WiVuS<#|(J)E} z%jyP9zyjzN!v2GAasq1q6B`M|S*1ZtQ&r4of|zjJaoVGpNFL ml%a&3Qs57mz aZ g~u z`-X5W-R_eZq~5H_*Pi=!)f+7+L`a}Clf2R|$~m#~Tl({{r>OrKR@;9P;-y`jRVmb= zzZ0d!M-X8Nf5fk1i?NCY4&=L0E7By;l$Mf55^ T{{h8h z^569 Yu8;%LyK=g1toWa9mXlhf$# zK7F+NVWZf)hwL6G>`RSBLevZw^8;~c6puZ_B56=ZsemkQ%=Q1? L^4AFujUHa~kf>qZyg^=}y%v-xvoFahvY5N#EM^q%x^Urwsl9tAck0lI zh_UTuOF9x|p|E06b+_YD4Zq{jl<)bG;=0FKb?)2x(cxseD1Jc%R9#qUNyIjaVcs~t z_#n5is&|6f>q$o9jNoU$#B}HT- (Fehoi>Dl~&rQK ECp1Lg}_nC!4t%zU$f~^q=Lewexj#{>iXB$KkLnq *>d}LRrC2d!_f2MJKcSLxFFzZ`f!2^GRyKWRfAu24k$6=s4#=! zcW#RqkFY1k=;E?oZBq0;Rps1tnozv*+$Ns+-Ddi0Vy6WqTe$Xu&xGCLyIJ7MqIL6X zHOI8cft5n;(2i>dImaey^(k`5x(~4(r6p4sbPQQq7_uu=8Qf=)E9_rcPDC4hm`bdQ zTaWI5|7>?N_CMSG9mo0Wj?!A+0Xn?MiKHqp9C{(rKf?qgWycN;l9PGTzK?*PNNw}L z(SZo|s*z`%k{6Y!sFiz7K7~V$`*N|F*vr&>Vm_F0!*{DY_22K>9FNoT+2BPhB6cV7 zE`hSmxL-*Fi_+@4G!6pZ8}`U%l7+hH6 aM3Fm_$T$VS+vm6gsv z?6bT0tA|AE=Kzq?Bj;V9&ia7-x)3?%@*t|RwG%SC!vOO8HRTh!+EQA_u7Jvw$i`so zJoc rpv18_mJh&jl(023y=4ii7T(; zSk0}^g_)oBZ}7zJbq |EP*;?=dGnu%wT()+!oh)C}*7T&u-!yQp% zbS?)J?>O1W8tg-g`U=yxKDhs(A)I_LF=NBUm;mqMz~1sJ@IdxKn1zmDh0=u`JGTuY z+^eQF`Z_k3vnh$hxmoK|4i{(Vul-e=oSXs=i;TAIFPXSGzBbm_(tYJ**_wsr6*(bD zP+m&BlB(WWC9i|ysscCRjQYNRd^cTZ{xQ66CG-9z+IHnq;mtm=Zo$f%vDw--lQcx! z`n|#qmA7zCVB-|+$I&hs@7C6LNOoU_u;wO_!7phCxdg|8CMbsv%LFR)3?Rd6A3ohM zMkdnT&3#wavbc|8e;q#MFMQ|~&DrsIGAKlB9)k`Qy|#3YKDR@*Zho?9e7M{Xb-58; zI}g6_`lnG-_iA_i^h^KkhB#HlMnJ*tzTXs8 )@Q~%x-RDxSM$wy@)5PJT{yIN=`pTjY1 OcBnj6g&vdVE(6#zu z4cq+*hn+pdaC-u3$Y<~X!L)!T_?T&A&Kq(&7Q!q^4wagKyB(O;4w#lskEb?=%+>Xo znoXZA+fSc6-!0#r)Y=^PaqdA2F)}~w##v$wc2QOSZ>qoP*wopRb32dwiQliM#UpXL zUQhM1TM!pPOx#uqkwXyro*>UQTtBg1Q7pysLTZs9GWvD-w(vwg2@2YRRv)8SgZ?1+ z#HUauJfmA*tWzXvaHVj689cEQ*mLUU@ rfuv zRSwR@k4H^KJ;xzas>vLGFg5=Oe(Wz|;E=9&gV|v^mG^5zb;$6&?AQAp&%#LiH{RcM^Mjf6Bl){Qtrtg*gf`DRi~=4v4;lF83HOC7 z88VYNwPiFh9^~!Isd^qKfv5sq9)Dh22DH0-Ix8zD44AaSW-}xd$a*F0es6C=hsC9t zr8!u;x3G&BB* epa*^j}=n!RX$(U!#5YYy%(zct0`-#%Qe-wbYjwml}p{4Dwt zzj8V{r-U0)vo1*p$7!`6GmDmdS|Qwk55q Z=-r^=D=)6^VOEH6U#~VfdjVe=TDlW?9R7q}O!88AJ zq3Hj)&@3g40uDun!#^r8v`>)2oCP~fb%nh`d^5&003&^57yvX3-q)pvzL1a*PhW=_ z_amPI0>qbDVO4dLUd`$fk1uDhaDb1i>#??>I9=)c!oFnI*p+<+`)aWZ1FAu#j}K}S zCU~?YnGKwy10&h(SB?+v6@xT_LNqyC_=H>t0}^vjY6@r|r~eF9zCI;vQxxv?RL1-T+u1#Kh{y?Mo|iGyTWGi$A& z^4G`fgW&rPs^DXNY&p9RZArfMFdHt*?#FYZpyx%a&wZvZ{l><|xM}MRzfM~p%n@3i z5P7W#*pzFHBNE$npbnoTb00olKQ|ns%_CVJNo|`tqjGz!q$xS$JY;_a?0_PhtE6~b zIFs5m9R(Pzc_9s9Wkd|Xfy5(BB|+(Bv*faU{UXR*wzo1IY8CC_*>KlRL<+(hxLG^T zK9B9uCXst81i`a@F3_;Ty*!TG#M7`=eLLYPAN%S3l4{dYxVsqp%Q9v`ZJ|SwodA#= z@{l5M8z1X+wARX~d#$pz6KKMbpupl8K4w+_b%w2(PyYKY^n1~wa~UAK1VuD+Jza$* z9W9z6{l78-{}-Q8z$rnRj*CKl1(Lz0+l&mm1pG3DC%COuy&J{A0yTXzx%HGi@rHviYL#n&tQ? zDfNml1}=g`#x2A;&9is6 u4|Nbri(`@k|Q3)A0YUb+DwKm=9mO3LzDOngpw!Q5}PbQ2jJuFU|KT1|LfWcNQ zl{b6CVnGwCS{L>Mx>TaD+g=3L^uG|`s*8u@eQZLDRD6t7u4)k06BD6YQV@-aA)Un~ zmr{<}8{25?CK}IFjfN~+6$Wa&&Kj`#%4p84DFcPqdQAGdY< 1Q z&?;;{EMnYz-V*dR^ez E8;G!8n{ A}XTi&`T21fyQ7pa11`pp0cNhIU_b{CgGqddR zc?<(GA?Ar@5)3DXpk2M3{4~9|T=RWhtZHNgKGc?~YiequNuqlh3VQEAEAo9VKmN7u zsF|LQ;Q;uVIr{N% Hj`nh!}_J&4eYOY zGF%)aY?6c*gDML&5)x4%L%832a_?<{mM#{H+2bQZ{YJF4QN485^mDbPSQcm9V?v(Q ze475Rc-5c9oU(GB{MUQL+V(qh-yfZ=E5J*;J?c`PUxt-Wqm)M^Q-~$-{fpY1?js=6 z>~cJ@#FEks_Yzw%PM**cI}WY (mJDWs{GHiBsRx^F?sbwf=_TKuL9oWQ?^ z9C1#}p&NZGZneB (*Q*}3J4fy!l^IV;~DBQ67vm}|9 JP$gVZz4_rz_)l NcRoc~Ueve%YcuqB&k`JAF&zHbh*(?$Qh=y(j=fXN;h)5+pO%|MjQ zF;qVTlWv5ijTjCBsT4?wxre#@9?MSnANZv)WV+AC6qinH0(B^m!i~I*CJ2pyQ-B9< zGYeq!`)P5BnQ@^mXQSjy5}0u~J1TglwuqK4RBLxX4(t3@SC`clR_CH6>=yn*HO==V z`|o9xl@P_Dv;Ccew> YFQ++!ZB86*!V534V`h4PwbZ zqGEgI49;lw6p$%73Zjdx^A0sDk1!$1>QU_O&+A=l5yo%$Yrp4K8>e_Xruf^|{vS_Y z85CF7bPGX4@ZjzeG`JHO+}+*X-Q8V-Gq}42CpZLmcNpB=zIonTb*tw0RGrgj_gdX+ zb#LCz!@AGXDSH0*TYcptaR3wA=KJwnslMky>g{1S#0H>t&dsffd-Tn`&gEL81iqXG zhsGVFTteiF`AI%2_+T@84u|$lhbw?^>KpQL`bZQd6cD5O=6EF&A71<1p3gd?Z=Zg0 zubM5K-VfQm?*QOFZjQ_u+d*W)f)oU5tZ?CB69Vrqyce(98y{HWZg5*`QGhmY$1(RQ z8k`L!G(s1_D{)RP@xa(cUNIGQXk4x^e2i0EIrbx&t*Eru5DuPj)gMmr99(b^!I@8_ z8i9hxB9usP%sl5&c`(AOXQd%ghRsySN8)S8GE8g0j{6I0E4j}iv(-p00D;`QVqtj| z5BLh_yq5(g uRyCIV;F8SPG|(4G<*)3;;T^pk9PF9iE5OWpK1B^?#A} z14p_$os;EQ=$WG-35$|4d9>G7>>GvTJbp? (G}{{tp*D?rQti~cm7ef zx7cWY+{xqjT0J&bQ!|o?`G=`rSf2S_FJ1G#vZYtEG_0-jZlE?yU6QZdmmImZAUIwD znTj4f1~-*1ph|>6XUk3NFmzXK?OB<&Rr}Sw+JoEx<~Z$gxY)a`Z)Ry1*NS;mp27Cd z)#{~XNuHP)Kv_I*g4=Ep{4wjDrcrf0)Ya_O%~>^MAo3kS{BY`c4nWaZi{6_Q23oNF zNSLN~Me9n$Tl{>}{a;{}!71C9=`$%r$&@*yhU5#tz&^>Ojg=lC E08 HOakbWF-Eau7AwI^&vCzD1^5?Y3t_6N} z;o2AD*vStyb34W8hcIHkf(&O4D$ON>_o1jUtKNds+77qp21>9LpkUm(X$N3R9G>v5 zYlI012fYzZbCV~IoW^p|x$fN{e~R(^PoR2}21k$-ot;L^03NsONd%c9#5FkRs_5d- zGk1@xa>5YA;d+cnn4{}p&DitC(rmy6&-W GOl-^)zyz*%|F42SQ`*X z0g27?D^g1V(@0?)zI=T*iD8&ua+t0EJEV1ksQHW9U0$n|=eTll;jBdl**5V0K`1i< zsnxH;-ULp+>=N;9pL0rHFgk^{BBQ#zVnJo;3&8({H7|?T-Vc0hv0Q0zZO^EM1CNaz zHrzM_+OL9Hj5_5+3nzOjaI+2kIeub+pG64kMMbulO^KdX0_cDdgVgy2<$!B6W$FCB z&12KDdABOtprlFnMXt-RFX=$PYu73!REVj(ifS`|m6h@7uWyexaDl9;&EXAAV^~ZO zSxD=$*K-g5XAH}H3>$#lXuzjEN>hjuXE$C3X#gYD=RqSE=x^7O@sXG6bvS>}Gt2W~ z(MYV^5fCL+uG=ey^ya|N8Q=`!LFm}v?;sdc{&phxv4B5MVf~LL6Y~A&Z#3h_@a^sD zDr6K-z1W|Hf836+`mJk7d>CZHi54d^=C$D>|EpoK!082iX`o>OPpy^Gz{)^`K@=M) zDRxo =<1P#uc3mWSGniU2Cs-iuY!3F14(X|I2qerkPoRd*pkJgx*~*>do6?^flf?=6pa&i=hKp{#wQ!)c^&}E z6)}&>NSO>_X)YrKw8g{|IEdA=K`XIo07Pj(b_=*M;bwX+aKw4(rNnw`Bg?)6L_o~W zao_Zu5_qM*LRCOptZUIacjG-yBgzyNNsJJim`5}J?)m@3oeu_Z(g>!aw4bIx{QDAV zvX9Kxy#$`fbV!d*NGQjbkf^=XYIE43O!xcRZ-}{&+T55B^24gO?O<}yn?Sn_XuC;% z|Ip2FkyyQ14@>lImd$$}mng!OpvpbA33~XXjdc6B6E9C+3fA|s(#*Bl=mWhdW4t{l zz~p~PaZOG(CwKJ_iKr@8Q(j673YPuVA*k_XHpTo+(ZfB5(9%-c!G Yo_d5a&F6%_(6Gw}kq|5$l)YO1^DZ0a*6%(5n6-{;PUoMMOX z&}F5n%>JuST4@l&HXjS*-mBvpMrow9D1k=DB&gYPd>Geo|A}FUh=k1cEE!E2yowz@ z&$G2U4-2~#M$Nx>6j{N|yi1MNM%#Emg*)P`4slHApE#pI5^61}Ut}_wYDXV@?U6+> zz0t}-rR!B#0E6UK+V@cd5TV*^#qUy8IpJiF2)pu45_A&lyX|TqH{dt3NR-5k6?pSX zh5}4N*-Uu(Vpf;9q5Z_<=0 w6p#RX!;8# zZmbwy(}8E}aBy<(v _F5l-B={t9r!nJrZH*KO4W2~P za_6Nv(?-7CnVV43jp7QWz>tzs76|jDL_m}8kZ63jXExeh?_}EF&c Y8}qo6MG~+76-5Tbek2=1f5NmfVHU17w`< zN#~_j%`PyF(rWwr!m$ -n^Yv9xoXHOjuySrWFP6E(o< zxb-29%hn4h3Z7^xbk&8)s_FT4zt`4QTkG?aS@$N_RU3}dIm<>(2P?k3;XOxDhdNC* zy!fw4IPz2!LIaZeltOArw4xHF&_!gL5IAFGh^D`A|5r=qeG3LYcA?rUM8#lB&wf?w zQwaET_55YVV*Y&hiy!Av6JAG0wwrkCrUPjELtpTIoOOx|DKkfgF3hn$y8EG8lwlRi zyM2MTvrnn)D2y5TFB1D7@IU)X^Lz~(vgUIFtgQz##GScSYP3$hHD7*+u%4TU6l5M} zj*acb k_b{F1Ge>^jN>;=i^if48zDF>4ePIiN$%++i*j%Dl}AGQNXut(vq;}*Q*I2^bD zM=G~NDaZIr=!i4_US`{Th1Z~Z0o6W!*y$^_*w}2ue2 A;J SaB{2Jl*>HXn)|<>ipbwadw$DpXbnry)e6gnfhP J`|^?Jq+gzXasO_*_dLz)N|H(jwy;8ayr16=#)k5|n79)YE; z8R*qM@?BlnNU7E~!PZrM@z>wzvq6JDSX|!}326BTJAazK4Bg`xvM43`@cX+YV_Rk+ z%M_AN;@LdsT>Ql{-Us&3*9e^ZOf+#94FK5RBsQGJ8P;0uFHcTpPw(BpysXjNb8_$S zy}OPH14$fx$Ac_M;tM(@MaC6s9QAK_z;BHSkN-ec8O$4A#Z;C)2Y8|)MaYPvMaY2$ z%%J;M9lP58(cog{tce#!-SieHqH=ludleRL6$UDk`CPAqocJ2P?Tw2Q+d7lXXCW;{ z_-VM9?^g;raNi`2)9B=_BpaD==lt{1!bcY!#aqON7n4?Feu-L3;>u@V#t^TZ5ZJh4 zakxVF_- (#>$dj#xz%#qN( )gK-w)YuLfC3+@_z3UQl-vz9_7@tIO(Mw{)0x3FMCrc1nISn$@8x?PEZP9eDccz zdr>{xYT-A_dyl2%FI;BMD^!DN*xl5s+qmkz;S&iv*JSIeM10$waCh$8(}xuS|B2QS zjYhm};E&kD@pPT} AC3Z6_sv>X=&$nEf#-ZKW#`AG z{{HKZ!|G}aMkzq6w8JN7znj<4>NuOi#-5a>Kyoj=(%hY&`fqiX!T6=AG{^Huo4USJ zxy#@~juY!0qhoyfTn~#lLx#@2^6K9H(vp?j<89er#X3a|Lt@2_nR#NV>b`ZmxAf3c z!RmsG$kQ=l)R@U{R~qdu_V=n8Ud(9bH;EDjdYu6~7S%*Ch9 LG9Il512Y~;-MFlZtcszw6eCgwpo39X7Wii(K1MDGFu(h zZ-4GtB6ezZ2o RWdW{%4SqNK@3lI0?PF>bceVevDSKa0IhWnPXB6qdZDq^--v)RKxXMx94nAkO& zV>5BV -BGAi86zE^kE-fAGeW@Y`)o?~skjK@$V z*i5z!xN3h{&Vb35G~R|8&XzvcrcLJ*tPZPfxU3DXtYIwghrz>1WdY~dk`oF%uHd}5 zHTxdlqsj9jJ74$o#iG+Y2e&GglUrzEp yXceEzg(t)4{OZMjF;e&n zj!{|4t`SCX(kO)iYL4Z?<)e3(Ui$dpEgxH S*DuWb500 zq?oP&@6y~Zk*yQq;EW+r_F=&&`|>}OxLbvl0qleLv*M9KILt6ATFNZ{FPz0lWxFcp zBGg?UOs*%nf_z|@l;UICJY5&(XcOan9zN+TJk5dXl?1CX_|IqrE{BwjK<*bU!WClv z`vS8=&CC;S6QcN&J$ZqH;&i=PHy1ot-Ifcxj+FuK^DDyZ7vD{i8`NG@D?U;x>M8H3 z{~(4I7H}y`o2cMf! mbSC*gCMP_54 zfUjhc8#b<7xwYt(R&kypCrZ{HA$POA@RV@c>5jWZSlsqXSpU4%9VO7t3CYeW{+GQC zd>Mb^3~S3Ef5SxDrb|E%=;U)+B{!h4OAf#^ 7kpiWSjexCP&-YxeI&Fb`%No0ZXG#1 zAzIq*KG#XLRzIxhN~Zc|yPByt?z8;Q>S)&p_}6s$@#(w3T>{s2g<$U#6=&QvkrP-& zA~ME;ucVEV%qmUt$+D~7%3JVoDF89<2pbn<<&ABeA2a{Ny#4>!)!h>+FKK(nmmjR9 zT$*?JqQ)uJ6c>Je=gahBP`>wt1`JLw?^8a=JUZkQqNHj2Jnf44Sp++~5W0sTbFOXk z&F=HGuW(PF(4RS(`1AMZfgMtg5fB+C_nq=SRvVekFLKo_jT<%`Jf3hzyE%60f~W}L zg) |sQG!Y6*Zf)?MLscA}tPGd;}pSf1KwqOVKlfzhC687#}%* z;A^iWXyX R73Lkbi0n#0Z8^v2sCbej3K3FL!;F$@_C7Hqn&KHW#;K zVP#c1(SCH1*F?y(zUjAYaWQ0Q=9V>lG&lqM@rwc@>=J`80zyy({KWqq)@g7;b(fv3 zz5RwLI3uY@33T8>6x;#(eNP>^2|2PR3Kpz7T*4O5d22t1kWy0@xORLNz|OL4`_H>2 z-s)W}ZNEnAwn&Tz(WN+d#DCOsKM#3Udr^`RqBP_qc>F*~R)02rwCY_-)y0&|r6pOF z@0uWsl-kwWF{1@Y;nZ((7g?eCa%jLUYQHRHyq49l-v`yO+=JiEL)8WN6_JjshP_o> z&@ZGT;a1SyTTUOCNZy-n)T{cACukNAqttMebqKTXtK&O6M5K>8VNNHNyEp%VIBFc4 z4K6i54ZaOI@bdoT5f`MV-cjU6S@@&!ek6Mrmb!W8KUJNF0NCRa5OstazQUTCp(5h{ z^-I`w9gRR)%>~a;fc<#LqYIo8N08kOOTk;t?e(YsaY1#% _ksYOu(TK z&*I8@C9mu_v~g{YI~)3w#0V5vM22tw0VI}`pw=J%`wz6h39(go_L#n`U5-kkAXByO zL6R!;z!8&@VVqV{?rQ;oyaad(B1r$tgB}Cnsk3v<@P=yw-0!!UCAQ*yi~n;g&;3q* zg{l*Z4AfL5{ );fL+9fK?4PH!Np#}YIhPwDvCoD5~b4l&zhoxS1$iO#tJmLR!oCIIr?})6J}$rJ^~yOTC%~ z^lFCAa-zm+u8x?CXE=n^QhEzzT63iIny3cjX+l~nU0og~VfjJM4MKH;FkPRMX}U~+ zBv6_!_JS>Y%F5VG*1e`l$8@y10ef-P8zN~@%lkh`kN^rNxACqrj?vB|*xMt=+JZr_ zZIizp3|a(CV^k`NS(!B5N*5ZLW0ZR?PV`cKuPG0Kk~BK=kqV+CC}kFn?oFX|^mgYw zrv-HEsf~_^LZYtcRop#q6LaUfVE{s1tmp~DRvw0-fMG(!zfiC*;pp{Xj)xYip@mNQ zQ_>UOyhVHhQymBMr{N6{6`cG7rdP-msrpcBAihdAP|)T}3Q18MXy_n{8`XQvo{NSj zWHqkJ>LNGRXfJj9Z*3B9-jmCu)bJyRW2sO5d21oIogUJL;))a^=j>e3zJqWy`FM65 z3;`C%HUGTKCQ1$oa8;rBfhQb7x-?TDI^g&*0;n;ZmgTb|92MEz#{vJU8{=)7kZRfQ zm0`8433ZH*waxJZ4iJpppg7xRQ?$(_3Lgh`e%*jn7I7S&ozfA `w9zAz(!l3E64 z`@bwuh9^a%P+<{AF>s^mb_#Z0HE$QZ2M)poMWZ}npFd(QG@A7I&sc8`)_P!d?&>uy zbzP}WC8>8^jU~McO%J|)DXR@lJNw48L9FS;-2Uc3;DUShP*)+nw(Fs@U5X6J75NJt z6@6O~!Ehv7(<&hiKjnV$ua`B`H2SYus@GmG*7mr0nk-|7*hc^6M*kdZ6MmGa2!&ar zJ$VvfIZtZHCMJ%Ta(g+~gl0~)dugeEDf_cNiePm)4 r>`jRo&nB@uEwsdNKdf zNsB=)d}3$Y=c8^4J3 H~PRJY`!zFwgJ?aK!I+jv%V)Pa&v)9|Y!P@+W|1gAVr!2ah zp@&VlUpTp6PYB8eN0v|_rp1mxc;mfe7kpwJGSBHq<*8nt%|E-I9I}I88YXwR@=<7j z30(Anu2_UI^n9p#b%JdE`d7tEKF<2C3?4|XA9QgrwLIyaLkyk^e=J%}pNZBQHV#dB zW5-+EgBk4?n5<_QERVH13%&blE(SYdq91;zUHt5Knwh+K=0&=7$H6{h>w_z2TsPHg z8*g`rx1FJT3sUzmD1oW #@)oo#adl~Sc$cTt6pqH`~8ihb1MWj966?yy*xVFJa-KwjH5{4nF zG;(X }k0RX8- 41b0Gg-2 zftIFxAAwt&K1)-zFAp2~UDuOLI=9tRdTx8>-8U28n7TgRqH8`z%)7nDO2G@-gQU%J zUY|4ap5C%=dpvH;d%PA0)qVG8YkhB`vKZTL4(0Wo*T6faH^zJ33$re~_9w;tKKDv< zJ3i&5{RQmzh?mZ?JN`hSh?82TyN>D|?RLHW?ILhxV%WsiaAWS=b- NQ?=22 z((C286s6U&qkV#>P}IHw>|{B13G>nWNd!fKo^)-ZB!eKOBu12!+9X`UTG*`(E^~dh z!7T?~k_drv8N8Grw2!e848^ox$pxtLRJ+#}Y WQ`n-E@(qKV;Q6-tiWWh%Eo_; 9 zigi{y{FW9QOgX_Q-X-ff}XRhjUb`RCS-bKHHs_F1l73yX3ZPo4L7=hs|K$ z0~JW%d%P%$wJ3rwuF)uqC2%#3z^$3la1n4)hY}7lvcwES4OWWEANeI5L_rloA=f~N z6b}FOS!WW$+#T;;OgCL`tLvy4N?qR_ij=gjp|Mhq|M5{et5stFYf7Q;XvFIEit19M z5#`$#!@7Wh5s1jR1Kfg_%@E*>nO(>sU!bQQNr_joF-`(<2ol;EKG%;4?!t(c0 z9^JwWMV->TANuO<8sLGV@(i`&^72KQtdb0|Nyrsz4lK1?#}h~54v@3@me)C<;QNd+ z(}$PhcUN+TWlOD_3g_#}^>y8c(AV{7WEd<$#6U*d=r3;!ml8F{NlPwL*3CCsgomQl z$CK4R57e`~14)*aUmznBqRxV+Tc%a ~ipwsm>^nX%LkkeP z(uJU~`Wmya;>F2}(4iCR85?fqd-pAKj5|ciy8CI6$#$io7^&z#DTh0|bI@v7^WO*t z=QYmI_`b=P?#wF~7@|zJN; PZCkUn7|!~A4B zJRt1cFyeI4>~OUWN3PZE_&AHti@ylLV@?>(dH!7CMY(FJXuQ^6yOB6M6M{Dk*K}Jk zO;P%LqE6FP)qr4{*5w`uAuBO*wb}5{&>3!GJL+wXvgX*S-f4S&EqKY_P_R5*+jJ28 zQ2XdfESgwbQ^Y&@oa3%{7wzNNp}?uTeqfI03nNC{8g64_86ts)_{*n}n8+3~%uf^I z{xc^`9!xV$3=A^f^39l_MY7QPxtC!_l7nn$t8-TVjDR#C`e8AiGA3=a#_YJ$7WfiZ z*mCEXbCm_tRC$wXh@_N}N@!k^O}l85$isgi; e!C}`U4x0Nfnb#w8aF6e&x9Iw~$-tvt@$NNV;E1CeMWn>LH z^AAx-{6T|8Wt%HrLu=*Tm*&zu?~CT1_H&@Q{%TG`9;F vJp&GGuh2dkSR|BjaQr7rnWG_uy1!V?o1GFO}HtYP( zyK5iW*xA|HI5?YK-SGawN@Eukkn`)li?@fpdWpe#4Tr-GiG>GK6-0 6s1eHZNbg hw4S!5^K^Y*`?V)uxY0TyBghDq9Dfd1vyjHZ?aAJ`jkVCp zcfEC6Uw9mEQa|v0LcWgM7iDyQoa{<^9tZ;leQd9*DlFtwJIiRD%3z|cx&edk<(_uz zYv8S#nDm@{EN4z}%8`2e-fTMQ2y`}wDvpFwak_ntbTD%4O5K29Hzl6h^51hR6N{>H zmLb0EmPk#H+#Pw`S7Y{iukY+Ao1O0`bH**hkQ9{kxXD4;Aw6zvLXniRnx~6-j>l7W z?MEL+f_{%AM|JrEjZ)%4iD+?=Mfgce81ETleKPkJX=e157aDSVE^2cwZnr9Sr!sc0 zE_%Z<-ELIsywY>LmOPxgC`j7OD52VsSATdR!# -J)7Lo(D znep+p{RG7+$k3kcQ{WNb16U#taOS^fQjnK8CdcmV%mnuB0D+0*;&E>7?uCVhEF`E_ zoGxzjOO_Z23jh>B*oP<(7reWM@6EXWK^ksJD3!N|yDH1>)38oSEn>tF9=)rql}9i< z+l-S01;QW{X)(SI43Bv3qTB+n^V(58Cw1X5&Hz-iTnT5485 U**paH4FU3fpS)J PjE_w9%aoOOZfF4`s6uRqkC3HO^YJTXUOXZxwhuyVa+-L@QGgY42BE z1uTS_MBXP9(aEmll?0u=vrS0Y-Kwbdi^$EH=#8@hc;lC8dioPLx__@7=i>rR(0B+D z`CTMTg&-}MF&I)g;Dc;HXW <2FBu4PXT#hF z10^}z5ivpikCoc?l~w5F*6iS6XF@_-*e }uTP;$fOrvlv<8`)-Lga7lxp zZAdN0Nk~i4Hp37W3A7&(UrN?#f+D9#R~k;NI5Urf4A`mNvqXnfD8q!c3kpoT^gMr9 zZy7vGd;WK5Sp*-(k~PG75~i#wo_wG%POWMrS+hu31ClAXk!P^}pszi1XYzAeWOq;S zEl+}XsX~3E7=R1*R!eRV({Nsi(| 7mkHrmM-dEX%HBs$W9Lwu%#%bcL8; z{^C^8L$yU+@+^86@cZKX&rWs;NH0`Sl|oXW)%4JO06c$~%R!olpoV5{K1(NASZ(G` zB?WOhMi&V4=Y~ 8!|8)?XVU(zIlmgWJ%`-<*7{c4daijc3Hp4T;jQ_+Yufz^F zWP&A~GZ2PJ>m(VGs5zV=>U +LaqAU`AcCh>U<$fBBXiAK{p?xrooqL;wW3a zTsPo!o5tq(L@M6Y+cU%T2x0?^@f7c6XT$8SR%}I$TC5la6nfzwI0i*8uXqA(77iZ1 zX@3(J4-=Pi$Ke}qUNW@E5&vir15h}6r%4E;3jxLty11A)L?jd>#0O$gx~a|0TynF* z@ `sH>??6X))uk=k{KrCz zwj7c-(e>&aYp1>0QQ$|(^h2dBX{{$|tEE7xBV(x}N3$DWmm6E7ADiYP7=$I$HJq&e zX=1M46%6&$KT8vv>-fGBt6y@ZN5oR^s(-Ki{uLoKUj!}?qOZ@=(fsqF!P`yn!ERx* z6L>?W&%ac>%^(p)fDouVq*u1Xdk`uhBFxgRj7UY07juz*@cOG95@yyXFGK_vk+YX( zJf*qliQ7vgE0lDm)q0soxC% Yjpchbye5+_;P1DIyOO=q$m~LWtt8N=Q~{ zHQ%LR!HvVX;dcroXhVzgDa5Ta@mlA5H{hVM#r3aro#1ryx^LRB7e04Pz0($>Xqc`y zX|eO!G6R5ES%zvY 7{aZ+!(6l;_jM1HQn5KW9tp)Dh)PvWN3{aOa4B8WH^VGCcjqu zg>909f<&0F;1igeiVTRgxB+h )+wBPkwnJO>55&1vEYej=6P0 OGN?;6ndC`5NgPA@UV|K<%HrO5)HHeG+)w}+L)7c>;z_! z`{dkO`@r7^%oxIChyXfBP=_Hrfi1jC-7rJZmFzmZ#Ef%^ejJ7}8ZSxVAJONUiZ8V| zK>P`6hAXLnWIn0MpiUDZRrcybEh&K=zw7Z*d)ZC{S>|OLTD)Z%D!g7a!lni7sZLNy zc;2Unyq&0=skxJ=dDMtU+fWklVoK?e{6gd|LzOoh<*SAAm4w>q5YjIT6G?d&*cne~ z-5ruw#Z @d+<-fwn!QzA51)ORUbTb{5rpUO&j*-}9RKygUcCJzjZjsN3sFzA6mVkuz?HVzKR zF7?sOsCVD4F7fvG x95#0}!N!7j)& z+TLbZR1}yENW;7Dhf#mIZw0m9rdb9lpeIDqnc=I12%Vc+GJ+N@i!9nFEODl)2uG{% z Io}2Or`$es wQ3z1@3Ytx*DmW)mIi6eeb +?PV@8XTjwYcB9xMrPeo%X>U!}hD*9|*6M0&0!)^Vma7<&6axBD5DT_&rpLnRf)S zUqk~WvtH1fZ0bmB%F6rd812Q`f2=M#%SLGF^b(FTVdRT@T5jFb(aaTs0+%tq+#F8V zLpjg9B)jXquTgKl{pg_YDif$~qHWa5BX8WN?Ql-v*BsllnfN95PQ%0NS(F;F$|G+L z$4XS=glwK{6CoC+^(o>3TEiSJ+fjuchkjpmXn|}u4WaT*wS0L8xNHNTD1H?zhb(RS z>C>yzooM50kq#pEDYOoHG{Ff+yu7}KhlPoW_3C3u{*0Co`2de900dL$6Q^ Zzo9 z%*%ApOzA0ZSC+t5;6h4v_xzaBjbo0$Px#5d;9|}m^i>93MIH&3+yh1^3|j&|M$FHE zif45hYKNC@25wINX^+K2>nr~_1kIb{*Dwhrf_VM{ugd8q16Zp%mNz3M%4fTu6V#=z zrdJ0W0aA6u@i^~dPc8dPR9iHV_mUcJU;P!;cB<%z(uovI1v+0N&wIF#-Yl+{Be7&D z5)2P#tlf1SHth2}T$!48Nx+*qW-`ea%@==zGZLuCWC6L*Ic39xzSWYd))NaqiWzL~ zVPa{DV0(RERx7xwvm=m+-7A0AMFkL!Y42-oQ|+#Oap@|p U}{Ab3V6{BTqZmn%?tZdvRLgx8YKRn7acrEglsj4k6f3Sdod&GsM z4xEra{f(*Nd*G`eNs~f#Ydihj>stHsCD@t^!Vne#MjQ&NdFr5ba=%$7xq5XN6++1A zd-*6kKO9L^@iT<_+X=R1`o;|}nIGN0uaGYv!oT&e(Di8=&KoV>-Clf}(o8xk2xm@# z(tXY&Pj)bCUlTdk1HO2`{FKe7+ER`z`@&CqrJ|Ul8bi5+!_k^=`x_FMBm%Al2uEUp z*N |n&BzOdy0>Uq#?iB-- zK37=k -X8qN#Lp0%%N){fvKrv%1;~R2Z}GjouZh#K|=@L zO1w;BITGu*qyf|l^1fg)(5`=3MiyZ~g+MhgS7ZPEqk3Bkpl+#{-YOvoNIh+RJL74n zr-KRHMOxCOO;=f1D#ydabD?USG-21avFH@;Clf(KLMEg5%NIiV7w{i{3J3Ei@9}|p z+J?e=cZ8O&vBC)N%y>MnaAI)}NUp@k^;+Ns2x^tq)|R5=wz{sa_u$ g2}JKed}%#0(QE@P7Ae*b*k>s5#d(cr;;h%iJPv)3Q7 z4nF>Fr~s}H-1fLue)}@(&U}k)6)9=5dglxZW4al0=fAU=eQ1>ARrmreGA!C}lk+Rv ztzXlB^;IV~PL~A=BBTv&;vFW!pwlRZ3-lADNG236LO`qZ#59V98gqs=w@#KujK~m? zfGXKb^hS0UVMt>^OctEE(iZ~8S0J-4(S2ujpj-&ho|PQNI9@njXcNJC4F(e=ivR+w z`hj*5S0Q`zgCKC)NkD70`CdgTtudGi*lm65qQT@O0Qk^26wZ8MgAlF;7rlNSrDx%x zEc5gB1Pys5G-#Ziid^eLjy%` w=`}m zI+5-<-m_(qkl-puB0uo#=IcTm*{9 ykK)T%q%PzSzqi;AEuN~s)9YE!b!oSn1TYZ%Kn?Z#RY_D zvV`1}#O-xwp|8Ky3lUdJL_~} YsF|cMdPkZq<+C715N@?G_U!K}ln$DTOp7$&B@*FqWQ6Vuz z%|a9<1SesQHWsJ_pB9v3E@o$8RDpm=XQ5CcgE$ANCv2C{9ICP%vHVXcq*jjiIj^_Y zs(`v_SZQb~({{=)wNaNTw@1a8j7Y|9Z}r*Ot?Qmx1DJ*=S~~Qazj+;BC&w?mPh#Ki zPGZJd0I4}});7l{i5Slo?I#*??>1)VB7V0nLZ^JqrZxG#aV5G%$0JgULoi`8lPn#h z6}x^d*U)amQ@|mr7wac>8-yMspcZtpkFJ4JQfPB86+&3{-eB!$ECXW^kQb&4y;~Mk zqv=)(=9-M`m3{ugd8%f=yZ7qV9injCNm_HfVC6Lml*;k&PgYlXUA+niR~I(sq7&fX zOoQLa``^El_qSP#qejNb0u2rCme~%04iZrk;7mQ24v*+72qw#4eXHY_ehY-z3X_wQ z{}P7s_3aY(ZjZ~9)rAk!(#|@?pGT5y9xN6;(!XN;?Qeu0*%sR>cq1(N>QHYQXni{) zeEq9$pneZ+Zbw lcQam@D;?zF1KBB8Rwr z7LY^!kVEdU^&cs^lqyO|#S80vFx ngFyzP=6nt*F%LcNNKIZq8PkM@ zTz`Gg;!1XjWSCp-Dz|oiRw+If8p=;uuI!O;4~t0GU)Pia6w#qZR$qfjeP+HOLe7DU z7$JkZD;es)s5bEiBav(bVK~xj`A1wUxs6}K>UPzyat4Q1%&`Ul4+#AObHqNf1GZ$= z-PF}