aws-python-helper 1.3.0__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/PKG-INFO +1 -1
  2. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/__init__.py +3 -0
  3. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/dispatcher.py +9 -2
  4. aws_python_helper-1.5.0/aws_python_helper/repository/__init__.py +4 -0
  5. aws_python_helper-1.5.0/aws_python_helper/repository/audit.py +204 -0
  6. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/repository/base.py +26 -1
  7. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper.egg-info/PKG-INFO +1 -1
  8. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper.egg-info/SOURCES.txt +1 -0
  9. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/pyproject.toml +1 -1
  10. aws_python_helper-1.3.0/aws_python_helper/repository/__init__.py +0 -3
  11. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/README.md +0 -0
  12. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/__init__.py +0 -0
  13. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/auth_middleware.py +0 -0
  14. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/auth_validators.py +0 -0
  15. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/base.py +0 -0
  16. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/exceptions.py +0 -0
  17. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/fetcher.py +0 -0
  18. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/api/handler.py +0 -0
  19. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/context/__init__.py +0 -0
  20. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/context/session.py +0 -0
  21. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/context/state_validator.py +0 -0
  22. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/database/__init__.py +0 -0
  23. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/database/database_proxy.py +0 -0
  24. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/database/external_database_proxy.py +0 -0
  25. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
  26. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/database/mongo_manager.py +0 -0
  27. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/fargate/__init__.py +0 -0
  28. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/fargate/executor.py +0 -0
  29. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/fargate/fetcher.py +0 -0
  30. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/fargate/handler.py +0 -0
  31. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/fargate/task_base.py +0 -0
  32. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/invoker/__init__.py +0 -0
  33. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/invoker/api_client.py +0 -0
  34. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/invoker/exceptions.py +0 -0
  35. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  36. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  37. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/lambda_standalone/base.py +0 -0
  38. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  39. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
  40. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/model_index_sync/__init__.py +0 -0
  41. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/model_index_sync/base.py +0 -0
  42. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/model_query/__init__.py +0 -0
  43. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/model_query/base.py +0 -0
  44. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/permissions/__init__.py +0 -0
  45. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/permissions/catalog.py +0 -0
  46. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/sns/__init__.py +0 -0
  47. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/sns/publisher.py +0 -0
  48. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/sqs/__init__.py +0 -0
  49. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/sqs/consumer_base.py +0 -0
  50. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/sqs/fetcher.py +0 -0
  51. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/sqs/handler.py +0 -0
  52. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/utils/__init__.py +0 -0
  53. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/utils/json_encoder.py +0 -0
  54. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/utils/response.py +0 -0
  55. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper/utils/serializer.py +0 -0
  56. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  57. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper.egg-info/requires.txt +0 -0
  58. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/aws_python_helper.egg-info/top_level.txt +0 -0
  59. {aws_python_helper-1.3.0 → aws_python_helper-1.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ from .utils.serializer import serialize_mongo_types
26
26
 
27
27
  # Repository
28
28
  from .repository.base import Repository
29
+ from .repository.audit import current_actor, public_audit
29
30
 
30
31
  # Context
31
32
  from .context.session import Session, get_session, set_session
@@ -59,6 +60,8 @@ __all__ = [
59
60
  'FargateExecutor',
60
61
  'Lambda',
61
62
  'Repository',
63
+ 'current_actor',
64
+ 'public_audit',
62
65
  'fargate_handler',
63
66
  'api_handler',
64
67
  'sqs_handler',
@@ -79,8 +79,15 @@ class Dispatcher:
79
79
  # 2. Authorization based on mode
80
80
  authorization = os.getenv('AUTHORIZATION', '').lower()
81
81
  requires_user = authorization in ('user', 'full', 'permission')
82
- requires_state = authorization in ('state', 'full')
83
- requires_permission = authorization == 'permission'
82
+ requires_permission = authorization == 'permission'
83
+
84
+ # Pedir el state es independiente de pedir un permiso, así que va por
85
+ # su propia variable. Los valores viejos `state` y `full` la implican,
86
+ # para que un servicio sin migrar siga comportándose igual.
87
+ requires_state = (
88
+ authorization in ('state', 'full')
89
+ or os.getenv('REQUIRES_STATE', '').lower() in ('1', 'true', 'yes')
90
+ )
84
91
 
85
92
  # 2a. Authenticate (if mode requires user)
86
93
  if requires_user:
@@ -0,0 +1,4 @@
1
+ from .base import Repository
2
+ from .audit import current_actor, public_audit
3
+
4
+ __all__ = ['Repository', 'current_actor', 'public_audit']
@@ -0,0 +1,204 @@
1
+ """
2
+ Automatic audit stamping for every write.
3
+
4
+ Records answer "who last touched this, and when" without any repository having to
5
+ remember to fill it in. The four fields —created_at, created_by, updated_at,
6
+ updated_by— are injected by wrapping the Motor collection, so call sites keep
7
+ using `self.collection.insert_one(...)` exactly as before.
8
+
9
+ Not every write comes from a person. A scraper, a queue consumer or a scheduled
10
+ task writes too, and the stamp says so: "nobody knows" and "the system did it"
11
+ are different answers.
12
+
13
+ Author is stored denormalised —id plus the name as it read at the time— so
14
+ reading a record never needs a second query, and renaming someone later does not
15
+ rewrite history.
16
+ """
17
+
18
+ import os
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Dict, List, Optional, Union
21
+
22
+ from ..context.session import get_session
23
+
24
+ USER = 'user'
25
+ SYSTEM = 'system'
26
+
27
+ CREATED_AT = 'created_at'
28
+ CREATED_BY = 'created_by'
29
+ UPDATED_AT = 'updated_at'
30
+ UPDATED_BY = 'updated_by'
31
+
32
+
33
+ def _system_actor() -> Dict[str, str]:
34
+ """The process doing the writing, when no person is behind it."""
35
+ service = os.getenv('SERVICE_CODE') or os.getenv('NAMESPACE') or 'constitution'
36
+ # Cada entrypoint deja su rastro: la lambda su nombre, el task el suyo.
37
+ process = (
38
+ os.getenv('PROCESS_NAME')
39
+ or os.getenv('AWS_LAMBDA_FUNCTION_NAME')
40
+ or os.getenv('TASK_NAME')
41
+ or service
42
+ )
43
+ return {'type': SYSTEM, 'id': service, 'name': process}
44
+
45
+
46
+ def current_actor() -> Dict[str, str]:
47
+ """Who is writing right now, according to the request-scoped session."""
48
+ try:
49
+ session = get_session()
50
+ except Exception:
51
+ return _system_actor()
52
+
53
+ user = getattr(session, 'user', None) if session else None
54
+ if not isinstance(user, dict):
55
+ return _system_actor()
56
+
57
+ user_id = user.get('_id') or user.get('id') or user.get('user_id')
58
+ if not user_id:
59
+ return _system_actor()
60
+
61
+ name = user.get('name') or user.get('email') or str(user_id)
62
+ return {'type': USER, 'id': str(user_id), 'name': name}
63
+
64
+
65
+ def _now() -> datetime:
66
+ return datetime.now(timezone.utc)
67
+
68
+
69
+ def stamp_new(document: Dict[str, Any]) -> Dict[str, Any]:
70
+ """The four fields on a document being created.
71
+
72
+ Whatever the caller set explicitly wins: this fills gaps, it does not
73
+ overrule a repository that knows better.
74
+ """
75
+ if not isinstance(document, dict):
76
+ return document
77
+
78
+ now, who = _now(), current_actor()
79
+ document.setdefault(CREATED_AT, now)
80
+ document.setdefault(UPDATED_AT, now)
81
+ document.setdefault(CREATED_BY, who)
82
+ document.setdefault(UPDATED_BY, who)
83
+ return document
84
+
85
+
86
+ def _merge_into_set(update: Dict[str, Any], values: Dict[str, Any], operator: str) -> None:
87
+ section = update.get(operator)
88
+ if not isinstance(section, dict):
89
+ section = {}
90
+ update[operator] = section
91
+ for key, value in values.items():
92
+ section.setdefault(key, value)
93
+
94
+
95
+ def stamp_update(
96
+ update: Union[Dict[str, Any], List[Dict[str, Any]]],
97
+ upsert: bool = False,
98
+ ) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
99
+ """The two 'updated' fields on a modification, and the 'created' ones on an upsert.
100
+
101
+ An update can also be an aggregation pipeline, and a pipeline takes stages,
102
+ not operators: there the stamp goes as one more `$set` stage at the end.
103
+ """
104
+ now, who = _now(), current_actor()
105
+ touched = {UPDATED_AT: now, UPDATED_BY: who}
106
+
107
+ if isinstance(update, list):
108
+ # En un pipeline `$set` es una etapa y pisa lo anterior a propósito: va al
109
+ # final para que el sello no lo tape una etapa posterior.
110
+ return update + [{'$set': {UPDATED_AT: now, UPDATED_BY: who}}]
111
+
112
+ if not isinstance(update, dict):
113
+ return update
114
+
115
+ # Un reemplazo entero —sin operadores— es un documento, no una modificación.
116
+ if update and not any(key.startswith('$') for key in update):
117
+ return stamp_new(dict(update))
118
+
119
+ update = dict(update)
120
+ _merge_into_set(update, touched, '$set')
121
+ if upsert:
122
+ # Si termina insertando, el registro nace ahora: created_* sólo en ese caso.
123
+ _merge_into_set(update, {CREATED_AT: now, CREATED_BY: who}, '$setOnInsert')
124
+ return update
125
+
126
+
127
+ class AuditedCollection:
128
+ """A Motor collection that stamps who wrote and when.
129
+
130
+ Only the write methods are intercepted; everything else —find, aggregate,
131
+ count_documents, create_index— passes straight through untouched.
132
+
133
+ `bulk_write` is deliberately left alone: its operations carry their own
134
+ filters and updates, and silently rewriting them would be harder to predict
135
+ than asking for the stamp explicitly.
136
+ """
137
+
138
+ __slots__ = ('_collection',)
139
+
140
+ def __init__(self, collection: Any):
141
+ object.__setattr__(self, '_collection', collection)
142
+
143
+ def __getattr__(self, name: str) -> Any:
144
+ return getattr(self._collection, name)
145
+
146
+ def __repr__(self) -> str:
147
+ return f'AuditedCollection({self._collection!r})'
148
+
149
+ async def insert_one(self, document: Dict[str, Any], *args: Any, **kwargs: Any):
150
+ return await self._collection.insert_one(stamp_new(document), *args, **kwargs)
151
+
152
+ async def insert_many(self, documents: Any, *args: Any, **kwargs: Any):
153
+ return await self._collection.insert_many(
154
+ [stamp_new(document) for document in documents], *args, **kwargs
155
+ )
156
+
157
+ async def update_one(self, filter: Any, update: Any, *args: Any, **kwargs: Any):
158
+ return await self._collection.update_one(
159
+ filter, stamp_update(update, kwargs.get('upsert', False)), *args, **kwargs
160
+ )
161
+
162
+ async def update_many(self, filter: Any, update: Any, *args: Any, **kwargs: Any):
163
+ return await self._collection.update_many(
164
+ filter, stamp_update(update, kwargs.get('upsert', False)), *args, **kwargs
165
+ )
166
+
167
+ async def find_one_and_update(self, filter: Any, update: Any, *args: Any, **kwargs: Any):
168
+ return await self._collection.find_one_and_update(
169
+ filter, stamp_update(update, kwargs.get('upsert', False)), *args, **kwargs
170
+ )
171
+
172
+ async def replace_one(self, filter: Any, replacement: Any, *args: Any, **kwargs: Any):
173
+ return await self._collection.replace_one(
174
+ filter, stamp_new(dict(replacement)), *args, **kwargs
175
+ )
176
+
177
+ async def find_one_and_replace(self, filter: Any, replacement: Any, *args: Any, **kwargs: Any):
178
+ return await self._collection.find_one_and_replace(
179
+ filter, stamp_new(dict(replacement)), *args, **kwargs
180
+ )
181
+
182
+
183
+ def public_audit(document: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
184
+ """Leaves the audit fields ready to show: name and type, dates in ISO.
185
+
186
+ The author travels as name plus type because a panel has to tell a person
187
+ from a process, not just print a string.
188
+ """
189
+ if not isinstance(document, dict):
190
+ return document
191
+
192
+ document = dict(document)
193
+ for field in (CREATED_BY, UPDATED_BY):
194
+ value = document.get(field)
195
+ if isinstance(value, dict):
196
+ document[field] = {'name': value.get('name'), 'type': value.get('type', USER)}
197
+ for field in (CREATED_AT, UPDATED_AT):
198
+ value = document.get(field)
199
+ if isinstance(value, datetime):
200
+ # Sin tzinfo Mongo devuelve naive, y el navegador lo leería como hora local.
201
+ if value.tzinfo is None:
202
+ value = value.replace(tzinfo=timezone.utc)
203
+ document[field] = value.isoformat()
204
+ return document
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple
13
13
  from ..database.mongo_manager import MongoManager
14
14
  from ..database.external_mongo_manager import ExternalMongoManager
15
15
  from ..context.session import get_session
16
+ from .audit import AuditedCollection
16
17
 
17
18
 
18
19
  class Repository(ABC):
@@ -35,6 +36,8 @@ class Repository(ABC):
35
36
  is_external (bool): Whether to use an external MongoDB cluster. Default: False.
36
37
  cluster_name (str): External cluster name. Required if is_external=True.
37
38
  indexes (list): List of index definitions to create automatically.
39
+ audit (bool): Stamp created_at/created_by/updated_at/updated_by on every
40
+ write. Default: True — no repository has to remember to do it.
38
41
 
39
42
  Usage:
40
43
  # Core repository — always uses 'core' database
@@ -138,6 +141,24 @@ class Repository(ABC):
138
141
  """Whether this repository uses an external MongoDB cluster. Default: False."""
139
142
  return False
140
143
 
144
+ @property
145
+ def audit(self) -> bool:
146
+ """
147
+ Whether writes carry created_at/created_by/updated_at/updated_by.
148
+
149
+ On by default, so a record always answers who last touched it and when
150
+ without any repository having to fill it in. The author is a person when
151
+ a request carries a token, and the process itself otherwise.
152
+
153
+ Turn it off only where the stamp would be noise: collections that already
154
+ are a log of who did what (sessions, tokens), or raw scraped data written
155
+ in bulk where the author is always the same process.
156
+
157
+ class TokenRepository(Repository):
158
+ audit = False
159
+ """
160
+ return True
161
+
141
162
  @property
142
163
  def cluster_name(self) -> Optional[str]:
143
164
  """
@@ -198,7 +219,11 @@ class Repository(ABC):
198
219
  else:
199
220
  db = MongoManager.get_database(self.database_name)
200
221
 
201
- self._collection_cache[key] = db[self.collection_name]
222
+ collection = db[self.collection_name]
223
+ # La envoltura sella las escrituras; todo lo demás pasa de largo.
224
+ self._collection_cache[key] = (
225
+ AuditedCollection(collection) if self.audit else collection
226
+ )
202
227
 
203
228
  return self._collection_cache[key]
204
229
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -42,6 +42,7 @@ aws_python_helper/model_query/base.py
42
42
  aws_python_helper/permissions/__init__.py
43
43
  aws_python_helper/permissions/catalog.py
44
44
  aws_python_helper/repository/__init__.py
45
+ aws_python_helper/repository/audit.py
45
46
  aws_python_helper/repository/base.py
46
47
  aws_python_helper/sns/__init__.py
47
48
  aws_python_helper/sns/publisher.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "1.3.0"
7
+ version = "1.5.0"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +0,0 @@
1
- from .base import Repository
2
-
3
- __all__ = ['Repository']