Skip to content

Commit 10e0c7d

Browse files
Merge pull request #51 from ivan-borovets/refactor-project-structure
2 parents ddd0483 + cef4774 commit 10e0c7d

53 files changed

Lines changed: 247 additions & 206 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ natural.
433433
│ └── ... # ports, enums, exceptions, etc.
434434
435435
├── application/... # application layer
436-
│ ├── commands/ # write operations, business-critical reads
436+
│ ├── commands/ # write ops, business-critical reads
437437
│ │ ├── create_user.py # interactor
438438
│ │ └── ... # other interactors
439439
│ ├── queries/ # optimized read operations
@@ -444,9 +444,9 @@ natural.
444444
│ └── ... # ports, exceptions, etc.
445445
446446
├── infrastructure/... # infrastructure layer
447-
│ ├── auth_session/... # auth context (session-based)
448-
│ ├── handlers/... # account handlers (log in, log out, sign up)
449-
│ └── ... # adapters, persistence, exceptions, etc.
447+
│ ├── adapters/... # port adapters
448+
│ ├── auth/... # auth context (session-based)
449+
│ └── ... # persistence, exceptions, etc.
450450
451451
├── presentation/... # presentation layer
452452
│ └── http/ # http interface

config/toml_config_manager.py

Lines changed: 104 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import rtoml
1111

12+
ConfigDict = dict[str, Any]
13+
ExportEnv = dict[str, str]
14+
1215
log = logging.getLogger(__name__)
1316

1417

@@ -55,6 +58,9 @@ def configure_logging(*, level: LoggingLevel = DEFAULT_LOG_LEVEL) -> None:
5558
# ENVIRONMENT & PATHS
5659

5760

61+
ENV_VAR_NAME: Final[str] = "APP_ENV"
62+
63+
5864
class ValidEnvs(StrEnum):
5965
"""
6066
Values should reflect actual directory names.
@@ -76,9 +82,7 @@ class DirContents(StrEnum):
7682
DOTENV_NAME = ".env"
7783

7884

79-
ENV_VAR_NAME: Final[str] = "APP_ENV"
80-
81-
BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parent.parent
85+
BASE_DIR_PATH: Final[Path] = Path(__file__).resolve().parents[1]
8286
CONFIG_PATH: Final[Path] = BASE_DIR_PATH / "config"
8387

8488
ENV_TO_DIR_PATHS: Final[Mapping[ValidEnvs, Path]] = MappingProxyType({
@@ -88,7 +92,7 @@ class DirContents(StrEnum):
8892
})
8993

9094

91-
def validate_env(*, env: str | None) -> ValidEnvs:
95+
def validate_env(env: str | None) -> ValidEnvs:
9296
if env is None:
9397
raise ValueError(f"{ENV_VAR_NAME} is not set.")
9498
try:
@@ -101,19 +105,33 @@ def validate_env(*, env: str | None) -> ValidEnvs:
101105

102106

103107
def get_current_env() -> ValidEnvs:
104-
env_value = os.getenv(ENV_VAR_NAME)
105-
return validate_env(env=env_value)
108+
return validate_env(os.getenv(ENV_VAR_NAME))
106109

107110

108111
# CONFIG READING
109112

110113

114+
def load_full_config(
115+
env: ValidEnvs,
116+
dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
117+
main_config: DirContents = DirContents.CONFIG_NAME,
118+
secrets_config: DirContents = DirContents.SECRETS_NAME,
119+
) -> ConfigDict:
120+
log.info("Reading config for environment: '%s'", env)
121+
config = read_config(env=env, config=main_config, dir_paths=dir_paths)
122+
try:
123+
secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths)
124+
except FileNotFoundError:
125+
log.warning("Secrets file not found. Full config will not contain secrets.")
126+
return config
127+
return merge_dicts(dict1=config, dict2=secrets)
128+
129+
111130
def read_config(
112-
*,
113131
env: ValidEnvs,
114-
config: DirContents,
115132
dir_paths: Mapping[ValidEnvs, Path],
116-
) -> dict[str, Any]:
133+
config: DirContents,
134+
) -> ConfigDict:
117135
dir_path = dir_paths.get(env)
118136
if dir_path is None:
119137
raise FileNotFoundError(f"No directory path configured for environment: {env}")
@@ -126,7 +144,7 @@ def read_config(
126144
return rtoml.load(file)
127145

128146

129-
def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, Any]:
147+
def merge_dicts(*, dict1: ConfigDict, dict2: ConfigDict) -> ConfigDict:
130148
result = dict1.copy()
131149
for key, value in dict2.items():
132150
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
@@ -136,31 +154,68 @@ def merge_dicts(*, dict1: dict[str, Any], dict2: dict[str, Any]) -> dict[str, An
136154
return result
137155

138156

139-
def load_full_config(
140-
*,
157+
# EXPORT PROCESSING
158+
159+
160+
EXPORT_SECTION: Final[str] = "export"
161+
EXPORT_FIELDS_KEY: Final[str] = "fields"
162+
163+
164+
def get_exported_env_variables(
141165
env: ValidEnvs,
142-
main_config: DirContents = DirContents.CONFIG_NAME,
143-
secrets_config: DirContents = DirContents.SECRETS_NAME,
144166
dir_paths: Mapping[ValidEnvs, Path] = ENV_TO_DIR_PATHS,
145-
) -> dict[str, Any]:
146-
log.info("Reading config for environment: '%s'", env)
147-
config = read_config(env=env, config=main_config, dir_paths=dir_paths)
148-
try:
149-
secrets = read_config(env=env, config=secrets_config, dir_paths=dir_paths)
150-
except FileNotFoundError:
151-
log.warning("Secrets file not found. Full config will not contain secrets.")
152-
return config
153-
return merge_dicts(dict1=config, dict2=secrets)
167+
) -> ExportEnv:
168+
config = load_full_config(env=env, dir_paths=dir_paths)
169+
export_fields = load_export_fields(env=env, dir_paths=dir_paths)
170+
return extract_export_fields_from_config(config=config, export_fields=export_fields)
154171

155172

156-
# EXPORT PROCESSING
173+
def load_export_fields(
174+
env: ValidEnvs,
175+
dir_paths: Mapping[ValidEnvs, Path],
176+
) -> list[str]:
177+
export_data = read_config(
178+
env=env,
179+
config=DirContents.EXPORT_NAME,
180+
dir_paths=dir_paths,
181+
)
182+
183+
export_section = export_data.get(EXPORT_SECTION)
184+
if not isinstance(export_section, dict):
185+
raise ValueError(
186+
f"Invalid {DirContents.EXPORT_NAME}: missing [{EXPORT_SECTION}] section"
187+
)
188+
189+
fields = export_section.get(EXPORT_FIELDS_KEY)
190+
if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
191+
raise ValueError(
192+
f"Invalid {DirContents.EXPORT_NAME}: "
193+
f"'{EXPORT_FIELDS_KEY}' must be a list of strings"
194+
)
195+
if not fields:
196+
raise ValueError(
197+
f"Invalid {DirContents.EXPORT_NAME}: '{EXPORT_FIELDS_KEY}' cannot be empty"
198+
)
157199

200+
return fields
158201

159-
def get_env_value_by_export_field(*, config: dict[str, Any], field: str) -> Any:
160-
parts = field.split(".")
202+
203+
def extract_export_fields_from_config(
204+
config: ConfigDict,
205+
export_fields: list[str],
206+
) -> ExportEnv:
207+
result: ExportEnv = {}
208+
for field in export_fields:
209+
str_value = get_env_value_by_export_field(config=config, field=field)
210+
env_key = "_".join(part.upper() for part in field.split("."))
211+
result[env_key] = str_value
212+
return result
213+
214+
215+
def get_env_value_by_export_field(*, config: ConfigDict, field: str) -> str:
161216
current = config
162-
for part in parts:
163-
if part not in current:
217+
for part in field.split("."):
218+
if not isinstance(current, dict) or part not in current:
164219
raise KeyError(f"Field '{field}' not found in config")
165220
current = current[part]
166221

@@ -169,85 +224,59 @@ def get_env_value_by_export_field(*, config: dict[str, Any], field: str) -> Any:
169224
f"Field '{field}' cannot be converted to string: "
170225
f"got {type(current).__name__}",
171226
)
227+
172228
try:
173229
return str(current)
174230
except (TypeError, ValueError) as e:
175231
raise ValueError(f"Field '{field}' cannot be converted to string: {e!s}") from e
176232

177233

178-
def extract_exported(
179-
*,
180-
config: dict[str, Any],
181-
export_fields: list[str],
182-
) -> dict[str, str]:
183-
result: dict[str, str] = {}
184-
for field in export_fields:
185-
str_value = get_env_value_by_export_field(config=config, field=field)
186-
env_key = "_".join(part.upper() for part in field.split("."))
187-
result[env_key] = str_value
188-
return result
189-
190-
191-
def load_export_fields(*, env: ValidEnvs) -> tuple[dict[str, Any], list[str]]:
192-
config = load_full_config(env=env)
193-
export_data = read_config(
194-
env=env,
195-
config=DirContents.EXPORT_NAME,
196-
dir_paths=ENV_TO_DIR_PATHS,
197-
)
198-
if "export" not in export_data or "fields" not in export_data["export"]:
199-
raise ValueError("Invalid export.toml: missing [export] section or 'fields'")
200-
export_fields = export_data["export"]["fields"]
201-
return config, export_fields
234+
# DOTENV GENERATION
202235

203236

204-
# DOTENV GENERATION
237+
def write_dotenv_file(
238+
*,
239+
env: ValidEnvs,
240+
exported_fields: ExportEnv,
241+
generated_at: datetime | None = None,
242+
) -> None:
243+
if generated_at is None:
244+
generated_at = datetime.now(UTC)
205245

246+
dotenv_filename = f"{DirContents.DOTENV_NAME}.{env.value}"
247+
dotenv_path = ENV_TO_DIR_PATHS[env] / dotenv_filename
206248

207-
def write_dotenv_file(*, env: ValidEnvs, exported_fields: dict[str, str]) -> None:
208-
env_filename = f"{DirContents.DOTENV_NAME}.{env.value}"
209-
env_path = ENV_TO_DIR_PATHS[env] / env_filename
210249
header = [
211250
"# This .env file was automatically generated by toml_config_manager.",
212251
"# Do not edit directly. Make changes in config.toml or .secrets.toml instead.",
213252
"# Ensure values here match those in config files.",
214253
f"# Environment: {env}",
215-
f"# Generated: {datetime.now(UTC).isoformat()}",
254+
f"# Generated: {generated_at.isoformat()}",
216255
]
217256
body = [f"{key}={value}" for key, value in exported_fields.items()]
218257
body.append("")
219258

220-
with open(env_path, "w", encoding="utf-8") as f:
259+
with open(dotenv_path, "w", encoding="utf-8") as f:
221260
f.write("\n".join(header + body))
222261

223-
try:
224-
relative_path = env_path.relative_to(BASE_DIR_PATH)
225-
except ValueError:
226-
relative_path = env_path
227-
228262
log.info(
229263
"Dotenv for environment '%s' was successfully generated at '%s'! ✨",
230264
env.value,
231-
relative_path,
265+
str(dotenv_path.resolve()),
232266
)
233267

234268

235-
def generate_dotenv(*, env: ValidEnvs) -> None:
236-
config, export_fields = load_export_fields(env=env)
237-
exported_fields = extract_exported(config=config, export_fields=export_fields)
238-
write_dotenv_file(env=env, exported_fields=exported_fields)
239-
240-
241269
# ENTRY POINT
242270

243271

244272
def main() -> None:
245-
log_lvl: str = os.getenv(LOG_LEVEL_VAR_NAME, DEFAULT_LOG_LEVEL)
246-
validated_log_lvl: LoggingLevel = validate_logging_level(level=log_lvl)
247-
configure_logging(level=validated_log_lvl)
273+
log_lvl_str = os.getenv(LOG_LEVEL_VAR_NAME, DEFAULT_LOG_LEVEL)
274+
log_lvl = validate_logging_level(level=log_lvl_str)
275+
configure_logging(level=log_lvl)
248276

249-
current_env = get_current_env()
250-
generate_dotenv(env=current_env)
277+
env = get_current_env()
278+
exported_fields = get_exported_env_variables(env)
279+
write_dotenv_file(env=env, exported_fields=exported_fields)
251280

252281

253282
if __name__ == "__main__":

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ ignore = [
134134
#
135135
"src/app/domain/value_objects/base.py" = ["B024", ] # abstract-base-class-without-abstract-method
136136
"src/app/infrastructure/adapters/password_hasher_bcrypt.py" = ["E501"] # line-too-long
137-
"src/app/infrastructure/constants.py" = ["S105"] # hardcoded-password-string
137+
"src/app/infrastructure/auth/session/constants.py" = ["S105"] # hardcoded-password-string
138138
"src/app/presentation/http/auth/constants.py" = ["S105"] # hardcoded-password-string
139139
"src/app/presentation/http/exceptions/handlers.py" = ["RUF029", ] # unused-async
140140
"scripts/dishka/plot_dependencies_data.py" = ["T201", ] # print

scripts/dishka/plot_dependencies_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dishka import AsyncContainer, make_async_container
44

55
from app.setup.config.settings import AppSettings, load_settings
6-
from app.setup.ioc.registry import get_providers
6+
from app.setup.ioc.provider_registry import get_providers
77

88

99
def make_plot_data_container(settings: AppSettings) -> AsyncContainer:

src/app/application/common/services/authorization/authorize.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from app.application.common.constants import AUTHZ_NOT_AUTHORIZED
21
from app.application.common.exceptions.authorization import AuthorizationError
32
from app.application.common.services.authorization.base import (
43
Permission,
54
PermissionContext,
65
)
6+
from app.application.common.services.constants import AUTHZ_NOT_AUTHORIZED
77

88

99
def authorize[PC: PermissionContext](
File renamed without changes.

src/app/application/common/services/current_user.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import logging
22

3-
from app.application.common.constants import AUTHZ_NO_CURRENT_USER, AUTHZ_NOT_AUTHORIZED
43
from app.application.common.exceptions.authorization import AuthorizationError
54
from app.application.common.ports.access_revoker import AccessRevoker
65
from app.application.common.ports.identity_provider import IdentityProvider
76
from app.application.common.ports.user_command_gateway import UserCommandGateway
7+
from app.application.common.services.constants import (
8+
AUTHZ_NO_CURRENT_USER,
9+
AUTHZ_NOT_AUTHORIZED,
10+
)
811
from app.domain.entities.user import User
912

1013
log = logging.getLogger(__name__)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import Final
2+
3+
DB_CONSTRAINT_VIOLATION: Final[str] = "Database constraint violation."
4+
DB_COMMIT_DONE: Final[str] = "Commit was done."
5+
DB_COMMIT_FAILED: Final[str] = "Commit failed."
6+
DB_FLUSH_DONE: Final[str] = "Flush was done."
7+
DB_FLUSH_FAILED: Final[str] = "Flush failed."
8+
DB_QUERY_FAILED: Final[str] = "Database query failed."

src/app/infrastructure/adapters/main_transaction_manager_sqla.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
TransactionManager,
99
)
1010
from app.domain.exceptions.user import UsernameAlreadyExistsError
11-
from app.infrastructure.adapters.types import MainAsyncSession
12-
from app.infrastructure.constants import (
11+
from app.infrastructure.adapters.constants import (
1312
DB_COMMIT_DONE,
1413
DB_COMMIT_FAILED,
1514
DB_CONSTRAINT_VIOLATION,
1615
DB_FLUSH_DONE,
1716
DB_FLUSH_FAILED,
1817
DB_QUERY_FAILED,
1918
)
19+
from app.infrastructure.adapters.types import MainAsyncSession
2020
from app.infrastructure.exceptions.gateway import DataMapperError
2121

2222
log = logging.getLogger(__name__)

src/app/infrastructure/adapters/password_hasher_bcrypt.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def hash(self, raw_password: RawPassword) -> bytes:
2424
This issue can be resolved by applying `base64` encoding to the digest.
2525
The resulting `base64(hmac-sha256(password, pepper))` string is then ready for bcrypt hashing.
2626
Salt is added to this string before passing it to `bcrypt` for the final hashing step.
27+
Inspired by: https://blog.ircmaxell.com/2015/03/security-issue-combining-bcrypt-with.html
2728
"""
2829
base64_hmac_password: bytes = self._add_pepper(raw_password, self._pepper)
2930
salt: bytes = bcrypt.gensalt()

0 commit comments

Comments
 (0)