panther 3.9.0__py3-none-any.whl → 4.0.0__py3-none-any.whl

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 (52) hide show
  1. panther/__init__.py +1 -1
  2. panther/_load_configs.py +168 -171
  3. panther/_utils.py +26 -49
  4. panther/app.py +85 -105
  5. panther/authentications.py +86 -55
  6. panther/background_tasks.py +25 -14
  7. panther/base_request.py +38 -14
  8. panther/base_websocket.py +172 -94
  9. panther/caching.py +60 -25
  10. panther/cli/create_command.py +20 -10
  11. panther/cli/monitor_command.py +63 -37
  12. panther/cli/template.py +38 -20
  13. panther/cli/utils.py +32 -18
  14. panther/configs.py +65 -58
  15. panther/db/connections.py +139 -0
  16. panther/db/cursor.py +43 -0
  17. panther/db/models.py +64 -29
  18. panther/db/queries/__init__.py +1 -1
  19. panther/db/queries/base_queries.py +127 -0
  20. panther/db/queries/mongodb_queries.py +77 -38
  21. panther/db/queries/pantherdb_queries.py +59 -30
  22. panther/db/queries/queries.py +232 -117
  23. panther/db/utils.py +17 -18
  24. panther/events.py +44 -0
  25. panther/exceptions.py +26 -12
  26. panther/file_handler.py +2 -2
  27. panther/generics.py +163 -0
  28. panther/logging.py +7 -2
  29. panther/main.py +111 -188
  30. panther/middlewares/base.py +3 -0
  31. panther/monitoring.py +8 -5
  32. panther/pagination.py +48 -0
  33. panther/panel/apis.py +32 -5
  34. panther/panel/urls.py +2 -1
  35. panther/permissions.py +3 -3
  36. panther/request.py +6 -13
  37. panther/response.py +114 -34
  38. panther/routings.py +83 -66
  39. panther/serializer.py +131 -25
  40. panther/test.py +31 -21
  41. panther/utils.py +28 -16
  42. panther/websocket.py +7 -4
  43. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/METADATA +93 -71
  44. panther-4.0.0.dist-info/RECORD +57 -0
  45. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/WHEEL +1 -1
  46. panther/db/connection.py +0 -92
  47. panther/middlewares/db.py +0 -18
  48. panther/middlewares/redis.py +0 -47
  49. panther-3.9.0.dist-info/RECORD +0 -54
  50. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/LICENSE +0 -0
  51. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/entry_points.txt +0 -0
  52. {panther-3.9.0.dist-info → panther-4.0.0.dist-info}/top_level.txt +0 -0
panther/app.py CHANGED
@@ -1,32 +1,34 @@
1
1
  import functools
2
- from collections.abc import Callable
3
- from datetime import datetime, timedelta
4
2
  import logging
3
+ from datetime import timedelta
5
4
  from typing import Literal
6
5
 
7
6
  from orjson import JSONDecodeError
8
- from pydantic import ValidationError
7
+ from pydantic import ValidationError, BaseModel
9
8
 
10
- from panther import status
11
9
  from panther._utils import is_function_async
12
- from panther.caching import cache_key, get_cached_response_data, set_cache_response
10
+ from panther.caching import (
11
+ get_response_from_cache,
12
+ set_response_in_cache,
13
+ get_throttling_from_cache,
14
+ increment_throttling_in_cache
15
+ )
13
16
  from panther.configs import config
14
17
  from panther.exceptions import (
15
- APIException,
16
- AuthorizationException,
17
- InvalidPathVariableException,
18
- JsonDecodeException,
19
- MethodNotAllowed,
20
- ThrottlingException,
18
+ APIError,
19
+ AuthorizationAPIError,
20
+ JSONDecodeAPIError,
21
+ MethodNotAllowedAPIError,
22
+ ThrottlingAPIError,
23
+ BadRequestAPIError
21
24
  )
22
25
  from panther.request import Request
23
26
  from panther.response import Response
24
- from panther.throttling import Throttling, throttling_storage
25
- from panther.utils import round_datetime
27
+ from panther.serializer import ModelSerializer
28
+ from panther.throttling import Throttling
26
29
 
27
30
  __all__ = ('API', 'GenericAPI')
28
31
 
29
-
30
32
  logger = logging.getLogger('panther')
31
33
 
32
34
 
@@ -34,11 +36,11 @@ class API:
34
36
  def __init__(
35
37
  self,
36
38
  *,
37
- input_model=None,
38
- output_model=None,
39
+ input_model: type[ModelSerializer] | type[BaseModel] | None = None,
40
+ output_model: type[ModelSerializer] | type[BaseModel] | None = None,
39
41
  auth: bool = False,
40
42
  permissions: list | None = None,
41
- throttling: Throttling = None,
43
+ throttling: Throttling | None = None,
42
44
  cache: bool = False,
43
45
  cache_exp_time: timedelta | int | None = None,
44
46
  methods: list[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']] | None = None,
@@ -55,21 +57,21 @@ class API:
55
57
 
56
58
  def __call__(self, func):
57
59
  @functools.wraps(func)
58
- async def wrapper(request: Request, **path_variables) -> Response:
59
- self.request: Request = request # noqa: Non-self attribute could not be type hinted
60
+ async def wrapper(request: Request) -> Response:
61
+ self.request = request
60
62
 
61
63
  # 1. Check Method
62
64
  if self.methods and self.request.method not in self.methods:
63
- raise MethodNotAllowed
65
+ raise MethodNotAllowedAPIError
64
66
 
65
67
  # 2. Authentication
66
- self.handle_authentications()
68
+ await self.handle_authentication()
67
69
 
68
- # 3. Throttling
69
- self.handle_throttling()
70
+ # 3. Permissions
71
+ await self.handle_permission()
70
72
 
71
- # 4. Permissions
72
- self.handle_permissions()
73
+ # 4. Throttling
74
+ await self.handle_throttling()
73
75
 
74
76
  # 5. Validate Input
75
77
  if self.request.method in ['POST', 'PUT', 'PATCH']:
@@ -77,37 +79,33 @@ class API:
77
79
 
78
80
  # 6. Get Cached Response
79
81
  if self.cache and self.request.method == 'GET':
80
- if cached := get_cached_response_data(request=self.request, cache_exp_time=self.cache_exp_time):
82
+ if cached := await get_response_from_cache(request=self.request, cache_exp_time=self.cache_exp_time):
81
83
  return Response(data=cached.data, status_code=cached.status_code)
82
84
 
83
- # 7. Clean Path Variables
84
- self.clean_path_variables(func, path_variables)
85
-
86
- # 8. Put Request In kwargs (If User Wants It)
87
- kwargs = {}
88
- if req_arg := [k for k, v in func.__annotations__.items() if v == Request]:
89
- kwargs[req_arg[0]] = self.request
85
+ # 7. Put PathVariables and Request(If User Wants It) In kwargs
86
+ kwargs = self.request.clean_parameters(func)
90
87
 
91
- # 9. Call Endpoint
88
+ # 8. Call Endpoint
92
89
  if is_function_async(func):
93
- response = await func(**kwargs, **path_variables)
90
+ response = await func(**kwargs)
94
91
  else:
95
- response = func(**kwargs, **path_variables)
92
+ response = func(**kwargs)
96
93
 
97
- # 10. Clean Response
94
+ # 9. Clean Response
98
95
  if not isinstance(response, Response):
99
96
  response = Response(data=response)
100
- response._clean_data_with_output_model(output_model=self.output_model) # noqa: SLF001
97
+ if self.output_model and response.data:
98
+ response.data = response.apply_output_model(response.data, output_model=self.output_model)
101
99
 
102
- # 11. Set New Response To Cache
100
+ # 10. Set New Response To Cache
103
101
  if self.cache and self.request.method == 'GET':
104
- set_cache_response(
102
+ await set_response_in_cache(
105
103
  request=self.request,
106
104
  response=response,
107
105
  cache_exp_time=self.cache_exp_time
108
106
  )
109
107
 
110
- # 12. Warning CacheExpTime
108
+ # 11. Warning CacheExpTime
111
109
  if self.cache_exp_time and self.cache is False:
112
110
  logger.warning('"cache_exp_time" won\'t work while "cache" is False')
113
111
 
@@ -115,70 +113,51 @@ class API:
115
113
 
116
114
  return wrapper
117
115
 
118
- def handle_authentications(self) -> None:
119
- auth_class = config['authentication']
116
+ async def handle_authentication(self) -> None:
120
117
  if self.auth:
121
- if not auth_class:
118
+ if not config.AUTHENTICATION:
122
119
  logger.critical('"AUTHENTICATION" has not been set in configs')
123
- raise APIException
124
- user = auth_class.authentication(self.request)
125
- self.request.set_user(user=user)
120
+ raise APIError
121
+ self.request.user = await config.AUTHENTICATION.authentication(self.request)
126
122
 
127
- def handle_throttling(self) -> None:
128
- if throttling := self.throttling or config['throttling']:
129
- key = cache_key(self.request)
130
- time = round_datetime(datetime.now(), throttling.duration)
131
- throttling_key = f'{time}-{key}'
132
- if throttling_storage[throttling_key] > throttling.rate:
133
- raise ThrottlingException
123
+ async def handle_throttling(self) -> None:
124
+ if throttling := self.throttling or config.THROTTLING:
125
+ if await get_throttling_from_cache(self.request, duration=throttling.duration) + 1 > throttling.rate:
126
+ raise ThrottlingAPIError
134
127
 
135
- throttling_storage[throttling_key] += 1
128
+ await increment_throttling_in_cache(self.request, duration=throttling.duration)
136
129
 
137
- def handle_permissions(self) -> None:
130
+ async def handle_permission(self) -> None:
138
131
  for perm in self.permissions:
139
132
  if type(perm.authorization).__name__ != 'method':
140
133
  logger.error(f'{perm.__name__}.authorization should be "classmethod"')
141
- continue
142
- if perm.authorization(request=self.request) is False:
143
- raise AuthorizationException
134
+ raise AuthorizationAPIError
135
+ if await perm.authorization(self.request) is False:
136
+ raise AuthorizationAPIError
144
137
 
145
138
  def handle_input_validation(self):
146
139
  if self.input_model:
147
- validated_data = self.validate_input(model=self.input_model, request=self.request)
148
- self.request.set_validated_data(validated_data)
140
+ self.request.validated_data = self.validate_input(model=self.input_model, request=self.request)
149
141
 
150
142
  @classmethod
151
143
  def validate_input(cls, model, request: Request):
144
+ if isinstance(request.data, bytes):
145
+ raise BadRequestAPIError(detail='Content-Type is not valid')
146
+ if request.data is None:
147
+ raise BadRequestAPIError(detail='Request body is required')
152
148
  try:
153
- if isinstance(request.data, bytes):
154
- raise APIException(detail='Content-Type is not valid', status_code=status.HTTP_400_BAD_REQUEST)
155
- return model(**request.data)
149
+ # `request` will be ignored in regular `BaseModel`
150
+ return model(**request.data, request=request)
156
151
  except ValidationError as validation_error:
157
152
  error = {'.'.join(loc for loc in e['loc']): e['msg'] for e in validation_error.errors()}
158
- raise APIException(detail=error, status_code=status.HTTP_400_BAD_REQUEST)
153
+ raise BadRequestAPIError(detail=error)
159
154
  except JSONDecodeError:
160
- raise JsonDecodeException
161
-
162
- @staticmethod
163
- def clean_path_variables(func: Callable, request_path_variables: dict):
164
- for name, value in request_path_variables.items():
165
- for variable_name, variable_type in func.__annotations__.items():
166
- if name == variable_name:
167
- # Check the type and convert the value
168
- if variable_type is bool:
169
- request_path_variables[name] = value.lower() not in ['false', '0']
170
-
171
- elif variable_type is int:
172
- try:
173
- request_path_variables[name] = int(value)
174
- except ValueError:
175
- raise InvalidPathVariableException(value=value, variable_type=variable_type)
176
- break
155
+ raise JSONDecodeAPIError
177
156
 
178
157
 
179
158
  class GenericAPI:
180
- input_model = None
181
- output_model = None
159
+ input_model: type[ModelSerializer] | type[BaseModel] = None
160
+ output_model: type[ModelSerializer] | type[BaseModel] = None
182
161
  auth: bool = False
183
162
  permissions: list | None = None
184
163
  throttling: Throttling | None = None
@@ -186,40 +165,41 @@ class GenericAPI:
186
165
  cache_exp_time: timedelta | int | None = None
187
166
 
188
167
  async def get(self, *args, **kwargs):
189
- raise MethodNotAllowed
168
+ raise MethodNotAllowedAPIError
190
169
 
191
170
  async def post(self, *args, **kwargs):
192
- raise MethodNotAllowed
171
+ raise MethodNotAllowedAPIError
193
172
 
194
173
  async def put(self, *args, **kwargs):
195
- raise MethodNotAllowed
174
+ raise MethodNotAllowedAPIError
196
175
 
197
176
  async def patch(self, *args, **kwargs):
198
- raise MethodNotAllowed
177
+ raise MethodNotAllowedAPIError
199
178
 
200
179
  async def delete(self, *args, **kwargs):
201
- raise MethodNotAllowed
180
+ raise MethodNotAllowedAPIError
202
181
 
203
- @classmethod
204
- async def call_method(cls, *args, **kwargs):
205
- match kwargs['request'].method:
182
+ async def call_method(self, request: Request):
183
+ match request.method:
206
184
  case 'GET':
207
- func = cls().get
185
+ func = self.get
208
186
  case 'POST':
209
- func = cls().post
187
+ func = self.post
210
188
  case 'PUT':
211
- func = cls().put
189
+ func = self.put
212
190
  case 'PATCH':
213
- func = cls().patch
191
+ func = self.patch
214
192
  case 'DELETE':
215
- func = cls().delete
193
+ func = self.delete
194
+ case _:
195
+ raise MethodNotAllowedAPIError
216
196
 
217
197
  return await API(
218
- input_model=cls.input_model,
219
- output_model=cls.output_model,
220
- auth=cls.auth,
221
- permissions=cls.permissions,
222
- throttling=cls.throttling,
223
- cache=cls.cache,
224
- cache_exp_time=cls.cache_exp_time,
225
- )(func)(*args, **kwargs)
198
+ input_model=self.input_model,
199
+ output_model=self.output_model,
200
+ auth=self.auth,
201
+ permissions=self.permissions,
202
+ throttling=self.throttling,
203
+ cache=self.cache,
204
+ cache_exp_time=self.cache_exp_time,
205
+ )(func)(request=request)
@@ -1,18 +1,22 @@
1
1
  import logging
2
2
  import time
3
3
  from abc import abstractmethod
4
+ from datetime import timezone, datetime
4
5
  from typing import Literal
5
6
 
7
+ from panther.base_websocket import Websocket
6
8
  from panther.cli.utils import import_error
7
9
  from panther.configs import config
10
+ from panther.db.connections import redis
8
11
  from panther.db.models import BaseUser, Model
9
- from panther.exceptions import AuthenticationException
12
+ from panther.exceptions import AuthenticationAPIError
10
13
  from panther.request import Request
14
+ from panther.utils import generate_hash_value_from_string
11
15
 
12
16
  try:
13
17
  from jose import JWTError, jwt
14
18
  except ModuleNotFoundError as e:
15
- import_error(e, package='python-jose')
19
+ raise import_error(e, package='python-jose')
16
20
 
17
21
  logger = logging.getLogger('panther')
18
22
 
@@ -20,67 +24,79 @@ logger = logging.getLogger('panther')
20
24
  class BaseAuthentication:
21
25
  @classmethod
22
26
  @abstractmethod
23
- def authentication(cls, request: Request):
27
+ async def authentication(cls, request: Request | Websocket):
24
28
  """Return Instance of User"""
25
29
  msg = f'{cls.__name__}.authentication() is not implemented.'
26
30
  raise cls.exception(msg) from None
27
31
 
28
32
  @staticmethod
29
- def exception(message: str, /) -> type[AuthenticationException]:
33
+ def exception(message: str, /) -> type[AuthenticationAPIError]:
30
34
  logger.error(f'Authentication Error: "{message}"')
31
- return AuthenticationException
35
+ return AuthenticationAPIError
32
36
 
33
37
 
34
38
  class JWTAuthentication(BaseAuthentication):
35
39
  model = BaseUser
36
40
  keyword = 'Bearer'
37
41
  algorithm = 'HS256'
38
- HTTP_HEADER_ENCODING = 'iso-8859-1' # Header encoding (see RFC5987)
42
+ HTTP_HEADER_ENCODING = 'iso-8859-1' # RFC5987
39
43
 
40
44
  @classmethod
41
- def get_authorization_header(cls, request: Request) -> bytes:
42
- auth = request.headers.authorization
45
+ def get_authorization_header(cls, request: Request | Websocket) -> str:
46
+ if auth := request.headers.authorization:
47
+ return auth
48
+ msg = 'Authorization is required'
49
+ raise cls.exception(msg) from None
50
+
51
+ @classmethod
52
+ async def authentication(cls, request: Request | Websocket) -> Model:
53
+ auth_header = cls.get_authorization_header(request).split()
43
54
 
44
- if auth is None:
45
- msg = 'Authorization is required'
55
+ if len(auth_header) != 2:
56
+ msg = 'Authorization should have 2 part'
46
57
  raise cls.exception(msg) from None
47
58
 
48
- if isinstance(auth, str):
49
- try:
50
- auth = auth.encode(JWTAuthentication.HTTP_HEADER_ENCODING)
51
- except UnicodeEncodeError as e:
52
- raise cls.exception(e) from None
59
+ bearer, token = auth_header
53
60
 
54
- return auth
61
+ try:
62
+ token.encode(JWTAuthentication.HTTP_HEADER_ENCODING)
63
+ except UnicodeEncodeError as e:
64
+ raise cls.exception(e) from None
55
65
 
56
- @classmethod
57
- def authentication(cls, request: Request) -> Model:
58
- auth = cls.get_authorization_header(request).split()
59
- if not auth or auth[0].lower() != cls.keyword.lower().encode():
66
+ if bearer.lower() != cls.keyword.lower():
60
67
  msg = 'Authorization keyword is not valid'
61
68
  raise cls.exception(msg) from None
62
- if len(auth) != 2:
63
- msg = 'Authorization should have 2 part'
64
- raise cls.exception(msg) from None
65
69
 
66
- try:
67
- token = auth[1].decode()
68
- except UnicodeError:
69
- msg = 'Unicode Error'
70
+ if redis.is_connected and await cls._check_in_cache(token=token):
71
+ msg = 'User logged out'
70
72
  raise cls.exception(msg) from None
71
73
 
72
74
  payload = cls.decode_jwt(token)
73
- return cls.get_user(payload)
75
+ user = await cls.get_user(payload)
76
+ user._auth_token = token
77
+ return user
74
78
 
75
79
  @classmethod
76
- def get_user(cls, payload: dict) -> Model:
80
+ def decode_jwt(cls, token: str) -> dict:
81
+ """Decode JWT token to user_id (it can return multiple variable ... )"""
82
+ try:
83
+ return jwt.decode(
84
+ token=token,
85
+ key=config.JWT_CONFIG.key,
86
+ algorithms=[config.JWT_CONFIG.algorithm],
87
+ )
88
+ except JWTError as e:
89
+ raise cls.exception(e) from None
90
+
91
+ @classmethod
92
+ async def get_user(cls, payload: dict) -> Model:
77
93
  """Get UserModel from config, else use default UserModel from cls.model"""
78
94
  if (user_id := payload.get('user_id')) is None:
79
- msg = 'Payload does not have user_id'
95
+ msg = 'Payload does not have `user_id`'
80
96
  raise cls.exception(msg)
81
97
 
82
- user_model = config['user_model'] or cls.model
83
- if user := user_model.find_one(id=user_id):
98
+ user_model = config.USER_MODEL or cls.model
99
+ if user := await user_model.find_one(id=user_id):
84
100
  return user
85
101
 
86
102
  msg = 'User not found'
@@ -89,11 +105,11 @@ class JWTAuthentication(BaseAuthentication):
89
105
  @classmethod
90
106
  def encode_jwt(cls, user_id: str, token_type: Literal['access', 'refresh'] = 'access') -> str:
91
107
  """Encode JWT from user_id."""
92
- issued_at = time.time()
108
+ issued_at = datetime.now(timezone.utc).timestamp()
93
109
  if token_type == 'access':
94
- expire = issued_at + config['jwt_config'].life_time
110
+ expire = issued_at + config.JWT_CONFIG.life_time
95
111
  else:
96
- expire = issued_at + config['jwt_config'].refresh_life_time
112
+ expire = issued_at + config.JWT_CONFIG.refresh_life_time
97
113
 
98
114
  claims = {
99
115
  'token_type': token_type,
@@ -103,33 +119,48 @@ class JWTAuthentication(BaseAuthentication):
103
119
  }
104
120
  return jwt.encode(
105
121
  claims,
106
- key=config['jwt_config'].key,
107
- algorithm=config['jwt_config'].algorithm,
122
+ key=config.JWT_CONFIG.key,
123
+ algorithm=config.JWT_CONFIG.algorithm,
108
124
  )
109
125
 
110
126
  @classmethod
111
- def encode_refresh_token(cls, user_id: str) -> str:
112
- """Encode JWT from user_id."""
113
- return cls.encode_jwt(user_id=user_id, token_type='refresh')
127
+ def login(cls, user_id: str) -> dict:
128
+ """Return dict of access and refresh token"""
129
+ return {
130
+ 'access_token': cls.encode_jwt(user_id=user_id),
131
+ 'refresh_token': cls.encode_jwt(user_id=user_id, token_type='refresh')
132
+ }
114
133
 
115
134
  @classmethod
116
- def decode_jwt(cls, token: str) -> dict:
117
- """Decode JWT token to user_id (it can return multiple variable ... )"""
118
- try:
119
- return jwt.decode(
120
- token=token,
121
- key=config['jwt_config'].key,
122
- algorithms=[config['jwt_config'].algorithm],
123
- )
124
- except JWTError as e:
125
- raise cls.exception(e) from None
135
+ async def logout(cls, raw_token: str) -> None:
136
+ *_, token = raw_token.split()
137
+ if redis.is_connected:
138
+ payload = cls.decode_jwt(token=token)
139
+ remaining_exp_time = payload['exp'] - time.time()
140
+ await cls._set_in_cache(token=token, exp=int(remaining_exp_time))
141
+ else:
142
+ logger.error('`redis` middleware is required for `logout()`')
143
+
144
+ @classmethod
145
+ async def _set_in_cache(cls, token: str, exp: int) -> None:
146
+ key = generate_hash_value_from_string(token)
147
+ await redis.set(key, b'', ex=exp)
126
148
 
127
149
  @classmethod
128
- def login(cls, user_id: str) -> str:
129
- """Alias of encode_jwt()"""
130
- return cls.encode_jwt(user_id=user_id)
150
+ async def _check_in_cache(cls, token: str) -> bool:
151
+ key = generate_hash_value_from_string(token)
152
+ return bool(await redis.exists(key))
131
153
 
132
154
  @staticmethod
133
- def exception(message: str | JWTError | UnicodeEncodeError, /) -> type[AuthenticationException]:
155
+ def exception(message: str | JWTError | UnicodeEncodeError, /) -> type[AuthenticationAPIError]:
134
156
  logger.error(f'JWT Authentication Error: "{message}"')
135
- return AuthenticationException
157
+ return AuthenticationAPIError
158
+
159
+
160
+ class QueryParamJWTAuthentication(JWTAuthentication):
161
+ @classmethod
162
+ def get_authorization_header(cls, request: Request | Websocket) -> str:
163
+ if auth := request.query_params.get('authorization'):
164
+ return auth
165
+ msg = 'Authorization is required'
166
+ raise cls.exception(msg) from None
@@ -9,7 +9,6 @@ from typing import Callable, Literal
9
9
  from panther._utils import is_function_async
10
10
  from panther.utils import Singleton
11
11
 
12
-
13
12
  __all__ = (
14
13
  'BackgroundTask',
15
14
  'background_tasks',
@@ -40,6 +39,7 @@ class BackgroundTask:
40
39
  self._last_run: datetime.datetime | None = None
41
40
  self._timedelta: datetime.timedelta = datetime.timedelta(minutes=1)
42
41
  self._time: datetime.time | None = None
42
+ self._day_of_week: int | None = None
43
43
  self._unit: Literal['seconds', 'minutes', 'hours', 'days', 'weeks'] | None = None
44
44
 
45
45
  def interval(self, interval: int, /) -> Self:
@@ -89,18 +89,24 @@ class BackgroundTask:
89
89
  self._timedelta = datetime.timedelta(weeks=weeks)
90
90
  return self
91
91
 
92
- # TODO: Coming Soon
93
- # def on(
94
- # self,
95
- # day_of_week: Literal['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
96
- # /
97
- # ) -> Self:
98
- # """
99
- # Set day to schedule the task, useful on `.every_weeks()`
100
- # """
101
- # if self._unit != 'weeks':
102
- # logger.warning('`.on()` only useful when you are using `.every_weeks()`')
103
- # return self
92
+ def on(
93
+ self,
94
+ day_of_week: Literal['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
95
+ /
96
+ ) -> Self:
97
+ """
98
+ Set day to schedule the task, useful on `.every_weeks()`
99
+ """
100
+ week_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
101
+ if day_of_week not in week_days:
102
+ msg = f'Argument should be one of {week_days}'
103
+ raise TypeError(msg)
104
+
105
+ self._day_of_week = week_days.index(day_of_week)
106
+
107
+ if self._unit != 'weeks':
108
+ logger.warning('`.on()` only useful when you are using `.every_weeks()`')
109
+ return self
104
110
 
105
111
  def at(self, _time: datetime.time, /) -> Self:
106
112
  """
@@ -133,6 +139,11 @@ class BackgroundTask:
133
139
  if self._last_run and (self._last_run + self._timedelta) > now:
134
140
  return True
135
141
 
142
+ # Check day of week
143
+ if self._day_of_week is not None:
144
+ if self._day_of_week != now.weekday():
145
+ return True
146
+
136
147
  # We don't have time condition, so run
137
148
  if self._time is None:
138
149
  self._last_run = now
@@ -188,7 +199,7 @@ class BackgroundTasks(Singleton):
188
199
 
189
200
  def add_task(self, task: BackgroundTask):
190
201
  if self._initialized is False:
191
- logger.error('Task will be ignored, `BACKGROUND_TASKS` is not True in `core/configs.py`')
202
+ logger.error('Task will be ignored, `BACKGROUND_TASKS` is not True in `configs`')
192
203
  return
193
204
 
194
205
  if not self._is_instance_of_task(task):