aiteamutils 0.2.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,396 @@
1
+ """보안 관련 유틸리티."""
2
+ from datetime import datetime, timedelta, UTC
3
+ from typing import Dict, Any, Optional, Literal, Callable
4
+ from fastapi import Request, HTTPException, status
5
+ from functools import wraps
6
+ from jose import jwt, JWTError
7
+ from passlib.context import CryptContext
8
+
9
+ from .exceptions import CustomException, ErrorCode
10
+ from .database import DatabaseService
11
+ from .enums import ActivityType
12
+ from .config import settings
13
+
14
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
15
+
16
+ class RateLimitExceeded(CustomException):
17
+ """Rate limit 초과 예외."""
18
+
19
+ def __init__(
20
+ self,
21
+ detail: str,
22
+ source_function: str,
23
+ remaining_seconds: float,
24
+ max_requests: int,
25
+ window_seconds: int
26
+ ):
27
+ """Rate limit 초과 예외를 초기화합니다.
28
+
29
+ Args:
30
+ detail: 상세 메시지
31
+ source_function: 예외가 발생한 함수명
32
+ remaining_seconds: 다음 요청까지 남은 시간 (초)
33
+ max_requests: 허용된 최대 요청 수
34
+ window_seconds: 시간 윈도우 (초)
35
+ """
36
+ super().__init__(
37
+ ErrorCode.RATE_LIMIT_EXCEEDED,
38
+ detail=detail,
39
+ source_function=source_function,
40
+ metadata={
41
+ "remaining_seconds": remaining_seconds,
42
+ "max_requests": max_requests,
43
+ "window_seconds": window_seconds
44
+ }
45
+ )
46
+ self.remaining_seconds = remaining_seconds
47
+ self.max_requests = max_requests
48
+ self.window_seconds = window_seconds
49
+
50
+ class SecurityError(CustomException):
51
+ """보안 관련 기본 예외."""
52
+
53
+ def __init__(
54
+ self,
55
+ error_code: ErrorCode,
56
+ detail: str,
57
+ source_function: str,
58
+ original_error: Optional[Exception] = None
59
+ ):
60
+ """보안 관련 예외를 초기화합니다.
61
+
62
+ Args:
63
+ error_code: 에러 코드
64
+ detail: 상세 메시지
65
+ source_function: 예외가 발생한 함수명
66
+ original_error: 원본 예외
67
+ """
68
+ super().__init__(
69
+ error_code,
70
+ detail=detail,
71
+ source_function=f"security.{source_function}",
72
+ original_error=original_error
73
+ )
74
+
75
+ class TokenError(SecurityError):
76
+ """토큰 관련 예외."""
77
+
78
+ def __init__(
79
+ self,
80
+ detail: str,
81
+ source_function: str,
82
+ original_error: Optional[Exception] = None
83
+ ):
84
+ """토큰 관련 예외를 초기화합니다."""
85
+ super().__init__(
86
+ ErrorCode.INVALID_TOKEN,
87
+ detail=detail,
88
+ source_function=source_function,
89
+ original_error=original_error
90
+ )
91
+
92
+ class RateLimiter:
93
+ """Rate limit 관리 클래스."""
94
+
95
+ def __init__(self, max_requests: int, window_seconds: int):
96
+ """Rate limiter를 초기화합니다.
97
+
98
+ Args:
99
+ max_requests: 허용된 최대 요청 수
100
+ window_seconds: 시간 윈도우 (초)
101
+ """
102
+ self.max_requests = max_requests
103
+ self.window_seconds = window_seconds
104
+ self._cache: Dict[str, Dict[str, Any]] = {}
105
+
106
+ def is_allowed(self, key: str) -> bool:
107
+ """현재 요청이 허용되는지 확인합니다.
108
+
109
+ Args:
110
+ key: Rate limit 키
111
+
112
+ Returns:
113
+ bool: 요청 허용 여부
114
+ """
115
+ now = datetime.now(UTC)
116
+ rate_info = self._cache.get(key)
117
+
118
+ if rate_info is None or (now - rate_info["start_time"]).total_seconds() >= self.window_seconds:
119
+ self._cache[key] = {
120
+ "count": 1,
121
+ "start_time": now
122
+ }
123
+ return True
124
+
125
+ if rate_info["count"] >= self.max_requests:
126
+ return False
127
+
128
+ rate_info["count"] += 1
129
+ return True
130
+
131
+ def get_remaining_time(self, key: str) -> float:
132
+ """남은 시간을 반환합니다.
133
+
134
+ Args:
135
+ key: Rate limit 키
136
+
137
+ Returns:
138
+ float: 남은 시간 (초)
139
+ """
140
+ rate_info = self._cache.get(key)
141
+ if not rate_info:
142
+ return 0
143
+
144
+ now = datetime.now(UTC)
145
+ return max(
146
+ 0,
147
+ self.window_seconds - (now - rate_info["start_time"]).total_seconds()
148
+ )
149
+
150
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
151
+ """비밀번호를 검증합니다.
152
+
153
+ Args:
154
+ plain_password: 평문 비밀번호
155
+ hashed_password: 해시된 비밀번호
156
+
157
+ Returns:
158
+ bool: 비밀번호 일치 여부
159
+ """
160
+ return pwd_context.verify(plain_password, hashed_password)
161
+
162
+ def hash_password(password: str) -> str:
163
+ """비밀번호를 해시화합니다.
164
+
165
+ Args:
166
+ password: 평문 비밀번호
167
+
168
+ Returns:
169
+ str: 해시된 비밀번호
170
+ """
171
+ return pwd_context.hash(password)
172
+
173
+ def rate_limit(
174
+ max_requests: int,
175
+ window_seconds: int,
176
+ key_func: Optional[Callable] = None
177
+ ):
178
+ """Rate limiting 데코레이터."""
179
+ limiter = RateLimiter(max_requests, window_seconds)
180
+
181
+ def decorator(func: Callable) -> Callable:
182
+ @wraps(func)
183
+ async def wrapper(*args, **kwargs):
184
+ # Request 객체 찾기
185
+ request = None
186
+ for arg in args:
187
+ if isinstance(arg, Request):
188
+ request = arg
189
+ break
190
+ if not request:
191
+ for arg in kwargs.values():
192
+ if isinstance(arg, Request):
193
+ request = arg
194
+ break
195
+ if not request:
196
+ raise SecurityError(
197
+ ErrorCode.INTERNAL_ERROR,
198
+ detail="Request object not found",
199
+ source_function="rate_limit"
200
+ )
201
+
202
+ # Rate limit 키 생성
203
+ if key_func:
204
+ rate_limit_key = f"rate_limit:{key_func(request)}"
205
+ else:
206
+ client_ip = request.client.host
207
+ rate_limit_key = f"rate_limit:{client_ip}:{func.__name__}"
208
+
209
+ try:
210
+ if not limiter.is_allowed(rate_limit_key):
211
+ remaining_time = limiter.get_remaining_time(rate_limit_key)
212
+ raise RateLimitExceeded(
213
+ detail=f"Rate limit exceeded. Try again in {int(remaining_time)} seconds",
214
+ source_function=func.__name__,
215
+ remaining_seconds=remaining_time,
216
+ max_requests=max_requests,
217
+ window_seconds=window_seconds
218
+ )
219
+
220
+ return await func(*args, **kwargs)
221
+
222
+ except (RateLimitExceeded, SecurityError) as e:
223
+ raise e
224
+ except Exception as e:
225
+ raise SecurityError(
226
+ ErrorCode.INTERNAL_ERROR,
227
+ detail=str(e),
228
+ source_function="rate_limit",
229
+ original_error=e
230
+ )
231
+
232
+ return wrapper
233
+ return decorator
234
+
235
+ class TokenCreationError(SecurityError):
236
+ """토큰 생성 관련 예외."""
237
+
238
+ def __init__(
239
+ self,
240
+ detail: str,
241
+ source_function: str,
242
+ token_type: str,
243
+ original_error: Optional[Exception] = None
244
+ ):
245
+ """토큰 생성 예외를 초기화합니다.
246
+
247
+ Args:
248
+ detail: 상세 메시지
249
+ source_function: 예외가 발생한 함수명
250
+ token_type: 토큰 타입
251
+ original_error: 원본 예외
252
+ """
253
+ super().__init__(
254
+ ErrorCode.INTERNAL_ERROR,
255
+ detail=detail,
256
+ source_function=source_function,
257
+ original_error=original_error
258
+ )
259
+ self.token_type = token_type
260
+
261
+ async def create_jwt_token(
262
+ user_data: Dict[str, Any],
263
+ token_type: Literal["access", "refresh"],
264
+ db_service: DatabaseService,
265
+ log_model: Any,
266
+ request: Optional[Request] = None
267
+ ) -> str:
268
+ """JWT 토큰을 생성하고 로그를 기록합니다.
269
+
270
+ Args:
271
+ user_data: 사용자 데이터 (username, ulid 등 필수)
272
+ token_type: 토큰 타입 ("access" 또는 "refresh")
273
+ db_service: 데이터베이스 서비스
274
+ log_model: 로그를 저장할 모델 클래스
275
+ request: FastAPI 요청 객체
276
+
277
+ Returns:
278
+ str: 생성된 JWT 토큰
279
+
280
+ Raises:
281
+ TokenCreationError: 토큰 생성 실패 시
282
+ SecurityError: 기타 보안 관련 오류 발생 시
283
+ """
284
+ try:
285
+ # 필수 필드 검증
286
+ required_fields = {"username", "ulid"}
287
+ missing_fields = required_fields - user_data.keys()
288
+ if missing_fields:
289
+ raise TokenCreationError(
290
+ detail=f"Missing required fields: {', '.join(missing_fields)}",
291
+ source_function="create_jwt_token",
292
+ token_type=token_type
293
+ )
294
+
295
+ # 토큰 데이터 생성
296
+ if token_type == "access":
297
+ expires_at = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
298
+ token_data = {
299
+ "iss": settings.TOKEN_ISSUER,
300
+ "sub": user_data["username"],
301
+ "aud": settings.TOKEN_AUDIENCE,
302
+ "exp": expires_at,
303
+ "token_type": token_type,
304
+ **user_data
305
+ }
306
+ else: # refresh token
307
+ expires_at = datetime.now(UTC) + timedelta(days=14)
308
+ token_data = {
309
+ "iss": settings.TOKEN_ISSUER,
310
+ "sub": user_data["username"],
311
+ "exp": expires_at,
312
+ "token_type": token_type,
313
+ "user_ulid": user_data["ulid"]
314
+ }
315
+
316
+ try:
317
+ token = jwt.encode(
318
+ token_data,
319
+ settings.JWT_SECRET,
320
+ algorithm=settings.JWT_ALGORITHM
321
+ )
322
+ except Exception as e:
323
+ raise TokenCreationError(
324
+ detail="Failed to encode JWT token",
325
+ source_function="create_jwt_token",
326
+ token_type=token_type,
327
+ original_error=e
328
+ )
329
+
330
+ # 로그 생성
331
+ try:
332
+ activity_type = ActivityType.ACCESS_TOKEN_ISSUED if token_type == "access" else ActivityType.REFRESH_TOKEN_ISSUED
333
+ await db_service.create_log(
334
+ model=log_model,
335
+ log_data={
336
+ "type": activity_type,
337
+ "user_ulid": user_data["ulid"],
338
+ "token": token
339
+ },
340
+ request=request
341
+ )
342
+ except Exception as e:
343
+ # 로그 생성 실패는 토큰 생성에 영향을 주지 않음
344
+ pass
345
+
346
+ return token
347
+
348
+ except (TokenCreationError, SecurityError) as e:
349
+ raise e
350
+ except Exception as e:
351
+ raise SecurityError(
352
+ ErrorCode.INTERNAL_ERROR,
353
+ detail=str(e),
354
+ source_function="create_jwt_token",
355
+ original_error=e
356
+ )
357
+
358
+ async def verify_jwt_token(
359
+ token: str,
360
+ expected_type: Optional[Literal["access", "refresh"]] = None
361
+ ) -> Dict[str, Any]:
362
+ """JWT 토큰을 검증합니다."""
363
+ try:
364
+ # 토큰 디코딩
365
+ payload = jwt.decode(
366
+ token,
367
+ settings.JWT_SECRET,
368
+ algorithms=[settings.JWT_ALGORITHM],
369
+ audience=settings.TOKEN_AUDIENCE,
370
+ issuer=settings.TOKEN_ISSUER
371
+ )
372
+
373
+ # 토큰 타입 검증 (expected_type이 주어진 경우에만)
374
+ if expected_type and payload.get("token_type") != expected_type:
375
+ raise TokenError(
376
+ detail=f"Expected {expected_type} token but got {payload.get('token_type')}",
377
+ source_function="verify_jwt_token"
378
+ )
379
+
380
+ return payload
381
+
382
+ except JWTError as e:
383
+ raise TokenError(
384
+ detail=str(e),
385
+ source_function="verify_jwt_token",
386
+ original_error=e
387
+ )
388
+ except (TokenError, SecurityError) as e:
389
+ raise e
390
+ except Exception as e:
391
+ raise SecurityError(
392
+ ErrorCode.INTERNAL_ERROR,
393
+ detail=str(e),
394
+ source_function="verify_jwt_token",
395
+ original_error=e
396
+ )
@@ -0,0 +1,188 @@
1
+ """유효성 검사 관련 유틸리티 함수들을 모아둔 모듈입니다."""
2
+
3
+ from typing import Any, Optional, Dict
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select
6
+ from pydantic import field_validator
7
+ import re
8
+
9
+ from .exceptions import ErrorCode, CustomException
10
+ from .database import DatabaseService
11
+ from .base_model import Base
12
+
13
+ def validate_with(validator_func, unique_check=None, skip_if_none=False):
14
+ """필드 유효성 검사 데코레이터
15
+ Args:
16
+ validator_func: 형식 검증 함수
17
+ unique_check: (table_name, field) 튜플. 지정되면 해당 테이블의 필드에 대해 중복 검사 수행
18
+ skip_if_none: None 값 허용 여부
19
+ """
20
+ def decorator(field_name: str):
21
+ def validator(cls, value, info: Any):
22
+ if skip_if_none and value is None:
23
+ return value
24
+
25
+ # 형식 검증 (필드명도 함께 전달)
26
+ validator_func(value, field_name)
27
+
28
+ # 중복 검사는 별도의 validator로 분리
29
+ if unique_check:
30
+ async def check_unique():
31
+ if not info or not hasattr(info, 'context'):
32
+ raise CustomException(
33
+ ErrorCode.VALIDATION_ERROR,
34
+ detail=f"{field_name}|{value}",
35
+ source_function=f"Validator.validate_{field_name}"
36
+ )
37
+
38
+ db_service = info.context.get("db_service")
39
+ if not db_service:
40
+ raise CustomException(
41
+ ErrorCode.VALIDATION_ERROR,
42
+ detail=f"{field_name}|{value}",
43
+ source_function=f"Validator.validate_{field_name}"
44
+ )
45
+
46
+ table_name, field = unique_check
47
+ table = Base.metadata.tables.get(table_name)
48
+ if not table:
49
+ raise CustomException(
50
+ ErrorCode.VALIDATION_ERROR,
51
+ detail=f"{field_name}|{value}",
52
+ source_function=f"Validator.validate_{field_name}"
53
+ )
54
+
55
+ await db_service.validate_unique_fields(
56
+ table,
57
+ {field: value},
58
+ source_function=f"Validator.validate_{field_name}"
59
+ )
60
+
61
+ # 중복 검사를 위한 별도의 validator 등록
62
+ field_validator(field_name, mode='after')(check_unique)
63
+
64
+ return value
65
+ return field_validator(field_name, mode='before')(validator)
66
+ return decorator
67
+
68
+ class Validator:
69
+ @staticmethod
70
+ def validate_email(email: str, field_name: str = "email") -> None:
71
+ """이메일 형식 검증을 수행하는 메서드."""
72
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
73
+ if not re.match(pattern, email):
74
+ raise CustomException(
75
+ ErrorCode.FIELD_INVALID_EMAIL,
76
+ detail=f"{field_name}|{email}",
77
+ source_function="Validator.validate_email"
78
+ )
79
+
80
+ @staticmethod
81
+ def validate_password(password: str, field_name: str = "password") -> None:
82
+ """비밀번호 규칙 검증을 수행하는 메서드."""
83
+ try:
84
+ if len(password) < 8:
85
+ raise CustomException(
86
+ ErrorCode.FIELD_INVALID_PASSWORD_LENGTH,
87
+ detail=f"{field_name}|{password}",
88
+ source_function="Validator.validate_password"
89
+ )
90
+
91
+ if not re.search(r'[A-Z]', password):
92
+ raise CustomException(
93
+ ErrorCode.FIELD_INVALID_PASSWORD_UPPER,
94
+ detail=f"{field_name}|{password}",
95
+ source_function="Validator.validate_password"
96
+ )
97
+
98
+ if not re.search(r'[0-9]', password):
99
+ raise CustomException(
100
+ ErrorCode.FIELD_INVALID_PASSWORD_NUMBER,
101
+ detail=f"{field_name}|{password}",
102
+ source_function="Validator.validate_password"
103
+ )
104
+
105
+ if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
106
+ raise CustomException(
107
+ ErrorCode.FIELD_INVALID_PASSWORD_SPECIAL,
108
+ detail=f"{field_name}|{password}",
109
+ source_function="Validator.validate_password"
110
+ )
111
+
112
+ except CustomException as ce:
113
+ raise ce
114
+ except Exception as e:
115
+ raise CustomException(
116
+ ErrorCode.VALIDATION_ERROR,
117
+ detail=f"{field_name}|{password}",
118
+ source_function="Validator.validate_password"
119
+ )
120
+
121
+ @staticmethod
122
+ def validate_id(value: str, field_name: str) -> None:
123
+ """ID 형식 검증을 수행하는 메서드."""
124
+ if len(value) < 3:
125
+ raise CustomException(
126
+ ErrorCode.FIELD_INVALID_ID_LENGTH,
127
+ detail=f"{field_name}|{value}",
128
+ source_function="Validator.validate_id"
129
+ )
130
+ if not re.match(r'^[a-z0-9_]+$', value):
131
+ raise CustomException(
132
+ ErrorCode.FIELD_INVALID_ID_CHARS,
133
+ detail=f"{field_name}|{value}",
134
+ source_function="Validator.validate_id"
135
+ )
136
+
137
+ @staticmethod
138
+ def validate_mobile(mobile: str, field_name: str = "mobile") -> None:
139
+ """휴대전화 번호 형식 검증을 수행하는 메서드."""
140
+ if not mobile: # Optional 필드이므로 빈 값 허용
141
+ return
142
+
143
+ pattern = r'^010-?[0-9]{4}-?[0-9]{4}$'
144
+ if not re.match(pattern, mobile):
145
+ raise CustomException(
146
+ ErrorCode.INVALID_MOBILE,
147
+ detail=f"{field_name}|{mobile}",
148
+ source_function="Validator.validate_mobile"
149
+ )
150
+
151
+ @staticmethod
152
+ def validate_phone(phone: str, field_name: str = "phone") -> None:
153
+ """일반 전화번호 형식 검증을 수행하는 메서드."""
154
+ if not phone: # Optional 필드이므로 빈 값 허용
155
+ return
156
+
157
+ pattern = r'^(0[2-6][1-5]?)-?([0-9]{3,4})-?([0-9]{4})$'
158
+ if not re.match(pattern, phone):
159
+ raise CustomException(
160
+ ErrorCode.FIELD_INVALID_PHONE,
161
+ detail=f"{field_name}|{phone}",
162
+ source_function="Validator.validate_phone"
163
+ )
164
+
165
+ @staticmethod
166
+ def validate_name(name: str, field_name: str = "name") -> None:
167
+ """이름 형식 검증을 수행하는 메서드."""
168
+ if not name:
169
+ raise CustomException(
170
+ ErrorCode.VALIDATION_ERROR,
171
+ detail=f"{field_name}|{name}",
172
+ source_function="Validator.validate_name"
173
+ )
174
+
175
+ if len(name) < 2 or len(name) > 100:
176
+ raise CustomException(
177
+ ErrorCode.VALIDATION_ERROR,
178
+ detail=f"{field_name}|{name}",
179
+ source_function="Validator.validate_name"
180
+ )
181
+
182
+ # 한글, 영문, 공백만 허용
183
+ if not re.match(r'^[가-힣a-zA-Z\s]+$', name):
184
+ raise CustomException(
185
+ ErrorCode.VALIDATION_ERROR,
186
+ detail=f"{field_name}|{name}",
187
+ source_function="Validator.validate_name"
188
+ )
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiteamutils
3
+ Version: 0.2.0
4
+ Summary: AI Team Utilities
5
+ Project-URL: Homepage, https://github.com/yourusername/aiteamutils
6
+ Project-URL: Issues, https://github.com/yourusername/aiteamutils/issues
7
+ Author: AI Team
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.8
12
+ Requires-Dist: fastapi
13
+ Requires-Dist: python-jose
14
+ Requires-Dist: sqlalchemy
15
+ Description-Content-Type: text/markdown
16
+
17
+ # AI Team Core Utils
18
+
19
+ AI Team Platform의 공통 유틸리티 패키지입니다.
20
+
21
+ ## 설치 방법
22
+
23
+ ```bash
24
+ pip install ai-team-core-utils
25
+ ```
26
+
27
+ ## 사용 예시
28
+
29
+ ```python
30
+ from ai_team_core_utils.database import DatabaseManager
31
+ from ai_team_core_utils.base_model import Base
32
+
33
+ # DB 매니저 초기화
34
+ db = DatabaseManager("postgresql+asyncpg://user:pass@localhost/db")
35
+
36
+ # DB 세션 사용
37
+ async with db.get_session() as session:
38
+ # DB 작업 수행
39
+ pass
40
+
41
+ # 예외 처리
42
+ from ai_team_core_utils.exceptions import CustomException, ErrorCode
43
+
44
+ try:
45
+ # 작업 수행
46
+ pass
47
+ except CustomException as e:
48
+ # 에러 처리
49
+ print(e.to_dict())
50
+ ```
51
+
52
+ ## 주요 기능
53
+
54
+ - 데이터베이스 유틸리티
55
+ - 세션 관리
56
+ - 트랜잭션 관리
57
+ - 기본 CRUD 작업
58
+
59
+ - 인증/인가 유틸리티
60
+ - JWT 토큰 관리
61
+ - 비밀번호 해싱
62
+ - Rate Limiting
63
+
64
+ - 예외 처리
65
+ - 표준화된 에러 코드
66
+ - 에러 체인 추적
67
+ - 로깅 통합
68
+
69
+ - 공통 모델
70
+ - 기본 모델 클래스
71
+ - 타입 검증
72
+ - 유효성 검사
@@ -0,0 +1,15 @@
1
+ aiteamutils/__init__.py,sha256=zmfBXBwNWdbJKCt1rmHk_czHJLmVF-oqRuqq8tf0t0U,1229
2
+ aiteamutils/base_model.py,sha256=ODEnjvUVoxQ1RPCfq8-uZTfTADIA4c7Z3E6G4EVsSX0,2708
3
+ aiteamutils/base_repository.py,sha256=0772JYHpF82vZzR8l21rDcZk8uVj6r52rqJdp150qiE,18927
4
+ aiteamutils/base_service.py,sha256=nW9sC0SHDIve3WJVUB3rAS_9XGTkIYJRaDxfqA0V3js,24727
5
+ aiteamutils/cache.py,sha256=tr0Yn8VPYA9QHiKCUzciVlQ2J1RAwNo2K9lGMH4rY3s,1334
6
+ aiteamutils/config.py,sha256=vC6k6E2-Y4mD0E0kw6WVgSatCl9K_BtTwrVFhLrhCzs,665
7
+ aiteamutils/database.py,sha256=U71cexPsSmMTKgp098I574PupqnuPltujI4QKHBG2Cc,29952
8
+ aiteamutils/dependencies.py,sha256=EJeVtq_lACuoheVhkX23N9xiak9bGD-t3-2JtlgBki0,4850
9
+ aiteamutils/enums.py,sha256=ipZi6k_QD5-3QV7Yzv7bnL0MjDz-vqfO9I5L77biMKs,632
10
+ aiteamutils/exceptions.py,sha256=YV-ISya4wQlHk4twvGo16I5r8h22-tXpn9wa-b3WwDM,15231
11
+ aiteamutils/security.py,sha256=AZszaTxVEGi1jU1sX3QXHGgshp1lVvd0xXvZejXvs_w,12643
12
+ aiteamutils/validators.py,sha256=BQA61f5raVAX0BGcTIS3Ht6CyCAdHgqDUmr76bZWgYE,7630
13
+ aiteamutils-0.2.0.dist-info/METADATA,sha256=ssjX9Y1HQqAY9vNB0MvN9wdILEtxPnIiXXWw3z4-An8,1569
14
+ aiteamutils-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ aiteamutils-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any