python-xbox 0.1.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 (64) hide show
  1. python_xbox-0.1.0.dist-info/METADATA +217 -0
  2. python_xbox-0.1.0.dist-info/RECORD +64 -0
  3. python_xbox-0.1.0.dist-info/WHEEL +4 -0
  4. python_xbox-0.1.0.dist-info/entry_points.txt +6 -0
  5. python_xbox-0.1.0.dist-info/licenses/LICENSE +20 -0
  6. pythonxbox/__init__.py +4 -0
  7. pythonxbox/api/__init__.py +0 -0
  8. pythonxbox/api/client.py +166 -0
  9. pythonxbox/api/language.py +76 -0
  10. pythonxbox/api/provider/__init__.py +0 -0
  11. pythonxbox/api/provider/account/__init__.py +73 -0
  12. pythonxbox/api/provider/account/models.py +11 -0
  13. pythonxbox/api/provider/achievements/__init__.py +164 -0
  14. pythonxbox/api/provider/achievements/models.py +133 -0
  15. pythonxbox/api/provider/baseprovider.py +22 -0
  16. pythonxbox/api/provider/catalog/__init__.py +86 -0
  17. pythonxbox/api/provider/catalog/const.py +15 -0
  18. pythonxbox/api/provider/catalog/models.py +428 -0
  19. pythonxbox/api/provider/cqs/__init__.py +85 -0
  20. pythonxbox/api/provider/cqs/models.py +59 -0
  21. pythonxbox/api/provider/gameclips/__init__.py +167 -0
  22. pythonxbox/api/provider/gameclips/models.py +58 -0
  23. pythonxbox/api/provider/lists/__init__.py +71 -0
  24. pythonxbox/api/provider/lists/models.py +33 -0
  25. pythonxbox/api/provider/mediahub/__init__.py +64 -0
  26. pythonxbox/api/provider/mediahub/models.py +82 -0
  27. pythonxbox/api/provider/message/__init__.py +135 -0
  28. pythonxbox/api/provider/message/models.py +96 -0
  29. pythonxbox/api/provider/people/__init__.py +193 -0
  30. pythonxbox/api/provider/people/models.py +252 -0
  31. pythonxbox/api/provider/presence/__init__.py +110 -0
  32. pythonxbox/api/provider/presence/models.py +53 -0
  33. pythonxbox/api/provider/profile/__init__.py +140 -0
  34. pythonxbox/api/provider/profile/models.py +47 -0
  35. pythonxbox/api/provider/ratelimitedprovider.py +79 -0
  36. pythonxbox/api/provider/screenshots/__init__.py +167 -0
  37. pythonxbox/api/provider/screenshots/models.py +56 -0
  38. pythonxbox/api/provider/smartglass/__init__.py +402 -0
  39. pythonxbox/api/provider/smartglass/models.py +186 -0
  40. pythonxbox/api/provider/titlehub/__init__.py +143 -0
  41. pythonxbox/api/provider/titlehub/models.py +106 -0
  42. pythonxbox/api/provider/usersearch/__init__.py +29 -0
  43. pythonxbox/api/provider/usersearch/models.py +17 -0
  44. pythonxbox/api/provider/userstats/__init__.py +164 -0
  45. pythonxbox/api/provider/userstats/models.py +44 -0
  46. pythonxbox/authentication/__init__.py +0 -0
  47. pythonxbox/authentication/manager.py +161 -0
  48. pythonxbox/authentication/models.py +162 -0
  49. pythonxbox/authentication/xal.py +348 -0
  50. pythonxbox/common/__init__.py +0 -0
  51. pythonxbox/common/exceptions.py +59 -0
  52. pythonxbox/common/filetimes.py +81 -0
  53. pythonxbox/common/models.py +34 -0
  54. pythonxbox/common/ratelimits/__init__.py +268 -0
  55. pythonxbox/common/ratelimits/models.py +23 -0
  56. pythonxbox/common/request_signer.py +190 -0
  57. pythonxbox/common/signed_session.py +60 -0
  58. pythonxbox/py.typed +0 -0
  59. pythonxbox/scripts/__init__.py +15 -0
  60. pythonxbox/scripts/authenticate.py +159 -0
  61. pythonxbox/scripts/change_gamertag.py +111 -0
  62. pythonxbox/scripts/friends.py +80 -0
  63. pythonxbox/scripts/search.py +43 -0
  64. pythonxbox/scripts/xal.py +113 -0
@@ -0,0 +1,162 @@
1
+ """Authentication Models."""
2
+
3
+ from datetime import datetime, timedelta, UTC
4
+
5
+ from pydantic import BaseModel, Field
6
+ from pydantic.dataclasses import dataclass
7
+
8
+ from pythonxbox.common.models import PascalCaseModel
9
+
10
+
11
+ def utc_now() -> datetime:
12
+ return datetime.now(UTC)
13
+
14
+
15
+ class XTokenResponse(PascalCaseModel):
16
+ issue_instant: datetime
17
+ not_after: datetime
18
+ token: str
19
+
20
+ def is_valid(self) -> bool:
21
+ return self.not_after > utc_now()
22
+
23
+
24
+ class XADDisplayClaims(BaseModel):
25
+ # {"xdi": {"did": "F.....", "dcs": "0"}}
26
+ xdi: dict[str, str]
27
+
28
+
29
+ class XADResponse(XTokenResponse):
30
+ display_claims: XADDisplayClaims
31
+
32
+
33
+ class XATDisplayClaims(BaseModel):
34
+ xti: dict[str, str]
35
+
36
+
37
+ class XATResponse(XTokenResponse):
38
+ display_claims: XATDisplayClaims
39
+
40
+
41
+ class XAUDisplayClaims(BaseModel):
42
+ xui: list[dict[str, str]]
43
+
44
+
45
+ class XAUResponse(XTokenResponse):
46
+ display_claims: XAUDisplayClaims
47
+
48
+
49
+ class XSTSDisplayClaims(BaseModel):
50
+ xui: list[dict[str, str]]
51
+
52
+
53
+ class XSTSResponse(XTokenResponse):
54
+ display_claims: XSTSDisplayClaims
55
+
56
+ @property
57
+ def xuid(self) -> str:
58
+ return self.display_claims.xui[0]["xid"]
59
+
60
+ @property
61
+ def userhash(self) -> str:
62
+ return self.display_claims.xui[0]["uhs"]
63
+
64
+ @property
65
+ def gamertag(self) -> str:
66
+ return self.display_claims.xui[0]["gtg"]
67
+
68
+ @property
69
+ def age_group(self) -> str:
70
+ return self.display_claims.xui[0]["agg"]
71
+
72
+ @property
73
+ def privileges(self) -> str:
74
+ return self.display_claims.xui[0]["prv"]
75
+
76
+ @property
77
+ def user_privileges(self) -> str:
78
+ return self.display_claims.xui[0]["usr"]
79
+
80
+ @property
81
+ def authorization_header_value(self) -> str:
82
+ return f"XBL3.0 x={self.userhash};{self.token}"
83
+
84
+
85
+ class OAuth2TokenResponse(BaseModel):
86
+ token_type: str
87
+ expires_in: int
88
+ scope: str
89
+ access_token: str
90
+ refresh_token: str | None = None
91
+ user_id: str
92
+ issued: datetime = Field(default_factory=utc_now)
93
+
94
+ def is_valid(self) -> bool:
95
+ return (self.issued + timedelta(seconds=self.expires_in)) > utc_now()
96
+
97
+
98
+ """XAL related models"""
99
+
100
+
101
+ @dataclass
102
+ class XalAppParameters:
103
+ app_id: str
104
+ title_id: str
105
+ redirect_uri: str
106
+
107
+
108
+ @dataclass
109
+ class XalClientParameters:
110
+ user_agent: str
111
+ device_type: str
112
+ client_version: str
113
+ query_display: str
114
+
115
+
116
+ class SisuAuthenticationResponse(PascalCaseModel):
117
+ msa_oauth_redirect: str
118
+ msa_request_parameters: dict[str, str]
119
+
120
+
121
+ class SisuAuthorizationResponse(PascalCaseModel):
122
+ device_token: str
123
+ title_token: XATResponse
124
+ user_token: XAUResponse
125
+ authorization_token: XSTSResponse
126
+ web_page: str
127
+ sandbox: str
128
+ use_modern_gamertag: bool | None = None
129
+
130
+
131
+ """Signature related models"""
132
+
133
+
134
+ class TitleEndpoint(PascalCaseModel):
135
+ protocol: str
136
+ host: str
137
+ host_type: str
138
+ path: str | None = None
139
+ relying_party: str | None = None
140
+ sub_relying_party: str | None = None
141
+ token_type: str | None = None
142
+ signature_policy_index: int | None = None
143
+ server_cert_index: list[int] | None = None
144
+
145
+
146
+ class SignaturePolicy(PascalCaseModel):
147
+ version: int
148
+ supported_algorithms: list[str]
149
+ max_body_bytes: int
150
+
151
+
152
+ class TitleEndpointCertificate(PascalCaseModel):
153
+ thumbprint: str
154
+ is_issuer: bool | None = None
155
+ root_cert_index: int
156
+
157
+
158
+ class TitleEndpointsResponse(PascalCaseModel):
159
+ end_points: list[TitleEndpoint]
160
+ signature_policies: list[SignaturePolicy]
161
+ certs: list[TitleEndpointCertificate]
162
+ root_certs: list[str]
@@ -0,0 +1,348 @@
1
+ """
2
+ Xbox Authentication Library
3
+
4
+ Authenticate with Windows Live Server and Xbox Live (used by mobile Xbox Apps)
5
+ """
6
+
7
+ import base64
8
+ import hashlib
9
+ import logging
10
+ import os
11
+ from collections.abc import Callable
12
+ from urllib import parse
13
+ import uuid
14
+
15
+ import httpx
16
+ from ms_cv import CorrelationVector
17
+
18
+ from pythonxbox.authentication.models import (
19
+ OAuth2TokenResponse,
20
+ SisuAuthenticationResponse,
21
+ SisuAuthorizationResponse,
22
+ TitleEndpointsResponse,
23
+ XADResponse,
24
+ XalAppParameters,
25
+ XalClientParameters,
26
+ XSTSResponse,
27
+ )
28
+ from pythonxbox.common.signed_session import SignedSession
29
+
30
+ log = logging.getLogger("xal.authentication")
31
+
32
+ APP_PARAMS_XBOX_BETA_APP = XalAppParameters(
33
+ app_id="000000004415494b",
34
+ title_id="177887386",
35
+ redirect_uri="ms-xal-000000004415494b://auth",
36
+ )
37
+
38
+ APP_PARAMS_XBOX_APP = XalAppParameters(
39
+ app_id="000000004c12ae6f",
40
+ title_id="328178078",
41
+ redirect_uri="ms-xal-000000004c12ae6f://auth",
42
+ )
43
+
44
+ APP_PARAMS_GAMEPASS = XalAppParameters(
45
+ app_id="000000004c20a908",
46
+ title_id="1016898439",
47
+ redirect_uri="ms-xal-000000004c20a908://auth",
48
+ )
49
+
50
+ APP_PARAMS_GAMEPASS_BETA = XalAppParameters(
51
+ app_id="000000004c20a908",
52
+ title_id="1016898439",
53
+ redirect_uri="ms-xal-public-beta-000000004c20a908://auth",
54
+ )
55
+
56
+ APP_PARAMS_FAMILY_SETTINGS = XalAppParameters(
57
+ app_id="00000000482C8F49",
58
+ title_id="1618633878",
59
+ redirect_uri="https://login.live.com/oauth20_desktop.srf",
60
+ )
61
+
62
+ CLIENT_PARAMS_IOS = XalClientParameters(
63
+ user_agent="XAL iOS 2021.11.20211021.000",
64
+ device_type="iOS",
65
+ client_version="15.6.1",
66
+ query_display="ios_phone",
67
+ )
68
+
69
+ CLIENT_PARAMS_ANDROID = XalClientParameters(
70
+ user_agent="XAL Android 2020.07.20200714.000",
71
+ device_type="Android",
72
+ client_version="8.0.0",
73
+ query_display="android_phone",
74
+ )
75
+
76
+
77
+ class XALManager:
78
+ def __init__(
79
+ self,
80
+ session: SignedSession,
81
+ device_id: uuid.UUID,
82
+ app_params: XalAppParameters,
83
+ client_params: XalClientParameters,
84
+ ) -> None:
85
+ self.session = session
86
+ self.device_id = device_id
87
+ self.app_params = app_params
88
+ self.client_params = client_params
89
+ self.cv = CorrelationVector()
90
+
91
+ @staticmethod
92
+ def _get_random_bytes(length: int) -> bytes:
93
+ return os.urandom(length)
94
+
95
+ @staticmethod
96
+ def _generate_code_verifier() -> str:
97
+ # https://tools.ietf.org/html/rfc7636
98
+ code_verifier = (
99
+ base64.urlsafe_b64encode(XALManager._get_random_bytes(32))
100
+ .decode()
101
+ .rstrip("=")
102
+ )
103
+ assert len(code_verifier) >= 43 and len(code_verifier) <= 128
104
+
105
+ return code_verifier
106
+
107
+ @staticmethod
108
+ def _get_code_challenge_from_code_verifier(code_verifier: str) -> str:
109
+ code_challenge = hashlib.sha256(code_verifier.encode()).digest()
110
+ # Base64 urlsafe encoding WITH stripping trailing '='
111
+ code_challenge = base64.urlsafe_b64encode(code_challenge).decode().rstrip("=")
112
+
113
+ return code_challenge
114
+
115
+ @staticmethod
116
+ def _generate_random_state() -> str:
117
+ state = str(uuid.uuid4()).encode()
118
+ # Base64 urlsafe encoding WITHOUT stripping trailing '='
119
+ return base64.b64encode(state).decode()
120
+
121
+ @staticmethod
122
+ async def get_title_endpoints(session: httpx.AsyncClient) -> TitleEndpointsResponse:
123
+ url = "https://title.mgt.xboxlive.com/titles/default/endpoints"
124
+ headers = {"x-xbl-contract-version": "1"}
125
+ params = {"type": 1}
126
+ resp = await session.get(url, headers=headers, params=params)
127
+ resp.raise_for_status()
128
+ return TitleEndpointsResponse(**resp.json())
129
+
130
+ async def request_device_token(self) -> XADResponse:
131
+ # Proof of possession: https://tools.ietf.org/html/rfc7800
132
+
133
+ device_id = str(self.device_id)
134
+
135
+ if self.client_params.device_type.lower() == "android":
136
+ # {decf45e4-945d-4379-b708-d4ee92c12d99}
137
+ device_id = f"{{{device_id}}}"
138
+ else:
139
+ # iOSs
140
+ # DECF45E4-945D-4379-B708-D4EE92C12D99
141
+ device_id = device_id.upper()
142
+
143
+ url = "https://device.auth.xboxlive.com/device/authenticate"
144
+ headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.get_value()}
145
+ data = {
146
+ "RelyingParty": "http://auth.xboxlive.com",
147
+ "TokenType": "JWT",
148
+ "Properties": {
149
+ "AuthMethod": "ProofOfPossession",
150
+ "Id": device_id,
151
+ "DeviceType": self.client_params.device_type,
152
+ "Version": self.client_params.client_version,
153
+ "ProofKey": self.session.request_signer.proof_field,
154
+ },
155
+ }
156
+
157
+ resp = await self.session.send_signed("POST", url, headers=headers, json=data)
158
+ resp.raise_for_status()
159
+ return XADResponse(**resp.json())
160
+
161
+ async def __oauth20_token_endpoint(self, json_body: dict) -> httpx.Response:
162
+ url = "https://login.live.com/oauth20_token.srf"
163
+ headers = {"MS-CV": self.cv.increment()}
164
+
165
+ # NOTE: No signature necessary
166
+ return await self.session.post(url, headers=headers, data=json_body)
167
+
168
+ async def exchange_code_for_token(
169
+ self, authorization_code: str, code_verifier: str
170
+ ) -> OAuth2TokenResponse:
171
+ post_body = {
172
+ "client_id": self.app_params.app_id,
173
+ "code": authorization_code,
174
+ "code_verifier": code_verifier,
175
+ "grant_type": "authorization_code",
176
+ "redirect_uri": self.app_params.redirect_uri,
177
+ "scope": "service::user.auth.xboxlive.com::MBI_SSL",
178
+ }
179
+ resp = await self.__oauth20_token_endpoint(post_body)
180
+ resp.raise_for_status()
181
+ return OAuth2TokenResponse(**resp.json())
182
+
183
+ async def refresh_token(self, refresh_token_jwt: str) -> httpx.Response:
184
+ post_body = {
185
+ "client_id": self.app_params.app_id,
186
+ "refresh_token": refresh_token_jwt,
187
+ "grant_type": "refresh_token",
188
+ "redirect_uri": self.app_params.redirect_uri,
189
+ "scope": "service::user.auth.xboxlive.com::MBI_SSL",
190
+ }
191
+
192
+ resp = await self.__oauth20_token_endpoint(post_body)
193
+ resp.raise_for_status()
194
+ return resp
195
+
196
+ async def request_sisu_authentication(
197
+ self, device_token_jwt: str, code_challenge: str, state: str
198
+ ) -> tuple[SisuAuthenticationResponse, str]:
199
+ """
200
+ Request Sisu authentication URL
201
+
202
+ Response holds authentication URL that needs to be called by the user
203
+ in webbrowser
204
+
205
+ Returns:
206
+ Tuple of (authentication response, sisu session id)
207
+ """
208
+ url = "https://sisu.xboxlive.com/authenticate"
209
+ headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.increment()}
210
+ post_body = {
211
+ "AppId": self.app_params.app_id,
212
+ "TitleId": self.app_params.title_id,
213
+ "RedirectUri": self.app_params.redirect_uri,
214
+ "DeviceToken": device_token_jwt,
215
+ "Sandbox": "RETAIL",
216
+ "TokenType": "code",
217
+ "Offers": ["service::user.auth.xboxlive.com::MBI_SSL"],
218
+ "Query": {
219
+ "display": self.client_params.query_display,
220
+ "code_challenge": code_challenge,
221
+ "code_challenge_method": "S256",
222
+ "state": state,
223
+ },
224
+ }
225
+
226
+ resp = await self.session.send_signed(
227
+ "POST", url, headers=headers, json=post_body
228
+ )
229
+ resp.raise_for_status()
230
+ return (
231
+ SisuAuthenticationResponse.model_validate_json(resp.content),
232
+ resp.headers["X-SessionId"],
233
+ )
234
+
235
+ async def do_sisu_authorization(
236
+ self, sisu_session_id: str, access_token_jwt: str, device_token_jwt: str
237
+ ) -> SisuAuthorizationResponse:
238
+ """
239
+ Sisu authorization
240
+
241
+ Returns:
242
+ Response with device-/title-/user-tokens
243
+ """
244
+ url = "https://sisu.xboxlive.com/authorize"
245
+ headers = {"MS-CV": self.cv.increment()}
246
+ post_body = {
247
+ "AccessToken": f"t={access_token_jwt}",
248
+ "AppId": self.app_params.app_id,
249
+ "DeviceToken": device_token_jwt,
250
+ "Sandbox": "RETAIL",
251
+ "SiteName": "user.auth.xboxlive.com",
252
+ "SessionId": sisu_session_id,
253
+ "ProofKey": self.session.request_signer.proof_field,
254
+ }
255
+
256
+ resp = await self.session.send_signed(
257
+ "POST", url, headers=headers, json=post_body
258
+ )
259
+ resp.raise_for_status()
260
+ return SisuAuthorizationResponse(**resp.json())
261
+
262
+ async def xsts_authorization(
263
+ self,
264
+ device_token_jwt: str,
265
+ title_token_jwt: str,
266
+ user_token_jwt: str,
267
+ relying_party: str,
268
+ ) -> XSTSResponse:
269
+ """
270
+ Request additional XSTS tokens for specific relying parties
271
+ """
272
+ url = "https://xsts.auth.xboxlive.com/xsts/authorize"
273
+ headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.increment()}
274
+ post_body = {
275
+ "RelyingParty": relying_party,
276
+ "TokenType": "JWT",
277
+ "Properties": {
278
+ "SandboxId": "RETAIL",
279
+ "DeviceToken": device_token_jwt,
280
+ "TitleToken": title_token_jwt,
281
+ "UserTokens": [user_token_jwt],
282
+ },
283
+ }
284
+
285
+ resp = await self.session.send_signed(
286
+ "POST", url, headers=headers, json=post_body
287
+ )
288
+ resp.raise_for_status()
289
+ return XSTSResponse(**resp.json())
290
+
291
+ async def auth_flow(
292
+ self, user_input_cb: Callable[[str], str]
293
+ ) -> SisuAuthorizationResponse:
294
+ """
295
+ Does the whole XAL/Sisu authentication flow
296
+
297
+ Args:
298
+ user_input_cb: User callback which takes args: (auth_url: str) and
299
+ returns the redirect URL (str)
300
+
301
+ Returns:
302
+ Sisu authorization response with all tokens
303
+ """
304
+
305
+ # Fetch device token
306
+ device_token_resp = await self.request_device_token()
307
+
308
+ # Generate states for OAUTH
309
+ code_verifier = self._generate_code_verifier()
310
+ code_challenge = self._get_code_challenge_from_code_verifier(code_verifier)
311
+ state = self._generate_random_state()
312
+
313
+ # Request Sisu authentication URL
314
+ (
315
+ sisu_authenticate_resp,
316
+ sisu_session_id,
317
+ ) = await self.request_sisu_authentication(
318
+ device_token_resp.token, code_challenge, state
319
+ )
320
+
321
+ # Prompt user for redirect URI after auth
322
+ redirect_uri = user_input_cb(sisu_authenticate_resp.msa_oauth_redirect)
323
+
324
+ # Ensure redirect URI looks like expected
325
+ if not redirect_uri.startswith(self.app_params.redirect_uri):
326
+ raise Exception("Wrong data passed as redirect URI")
327
+
328
+ # Parse URL query
329
+ query_params = dict(parse.parse_qsl(parse.urlsplit(redirect_uri).query))
330
+
331
+ # Extract code and state
332
+ resp_authorization_code = query_params["code"]
333
+ resp_state = query_params["state"]
334
+
335
+ if resp_state != state:
336
+ raise Exception("Response with non-matching state received")
337
+
338
+ # Exchange authentication code for tokens
339
+ tokens = await self.exchange_code_for_token(
340
+ resp_authorization_code, code_verifier
341
+ )
342
+
343
+ # Do Sisu authorization
344
+ sisu_authorization = await self.do_sisu_authorization(
345
+ sisu_session_id, tokens.access_token, device_token_resp.token
346
+ )
347
+
348
+ return sisu_authorization
File without changes
@@ -0,0 +1,59 @@
1
+ """
2
+ Special Exception subclasses
3
+ """
4
+
5
+ from typing import Any
6
+ from pythonxbox.common.ratelimits import RateLimit
7
+ from httpx import Response
8
+
9
+
10
+ class XboxException(Exception):
11
+ """Base exception for all Xbox exceptions to subclass"""
12
+
13
+ pass
14
+
15
+
16
+ class AuthenticationException(XboxException):
17
+ """Raised when logging in fails, likely due to incorrect auth credentials"""
18
+
19
+ pass
20
+
21
+
22
+ class TwoFactorAuthRequired(XboxException):
23
+ def __init__(self, message: str, server_data: dict[str, Any]) -> None:
24
+ """
25
+ Raised when 2FA is required
26
+
27
+ Args:
28
+ message (str): Exception message
29
+ server_data (dict): Server data dict, extracted js object from windows live auth request
30
+ """
31
+ super().__init__(message)
32
+ self.server_data = server_data
33
+
34
+
35
+ class InvalidRequest(XboxException):
36
+ def __init__(self, message: str, response: Response) -> None:
37
+ """
38
+ Raised when something is wrong with the request
39
+
40
+ Args:
41
+ message (str): error message returned by the server
42
+ response (requests.Response): Instance of :class:`requests.Response`
43
+
44
+ """
45
+ self.message = message
46
+ self.response = response
47
+
48
+
49
+ class NotFoundException(XboxException):
50
+ """Any exception raised due to a resource being missing will subclass this"""
51
+
52
+ pass
53
+
54
+
55
+ class RateLimitExceededException(XboxException):
56
+ def __init__(self, message: str, rate_limit: RateLimit) -> None:
57
+ self.message = message
58
+ self.rate_limit = rate_limit
59
+ self.try_again_in = rate_limit.get_reset_after()
@@ -0,0 +1,81 @@
1
+ # Copyright (c) 2009, David Buxton <david@gasmark6.com>
2
+ # All rights reserved.
3
+ #
4
+ # Redistribution and use in source and binary forms, with or without
5
+ # modification, are permitted provided that the following conditions are
6
+ # met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright
9
+ # notice, this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright
11
+ # notice, this list of conditions and the following disclaimer in the
12
+ # documentation and/or other materials provided with the distribution.
13
+ #
14
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
15
+ # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
16
+ # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
17
+ # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18
+ # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
20
+ # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
21
+ # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
22
+ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
23
+ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
24
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25
+ """Tools to convert between Python datetime instances and Microsoft times."""
26
+
27
+ from calendar import timegm
28
+ from datetime import datetime, timedelta, UTC
29
+
30
+ # http://support.microsoft.com/kb/167296
31
+ # How To Convert a UNIX time_t to a Win32 FILETIME or SYSTEMTIME
32
+ EPOCH_AS_FILETIME = 116444736000000000 # January 1, 1970 as MS file time
33
+ HUNDREDS_OF_NANOSECONDS = 10000000
34
+
35
+
36
+ ZERO = timedelta(0)
37
+ HOUR = timedelta(hours=1)
38
+
39
+
40
+ def dt_to_filetime(dt: datetime) -> int:
41
+ """Converts a datetime to Microsoft filetime format. If the object is
42
+ time zone-naive, it is forced to UTC before conversion.
43
+
44
+ >>> "%.0f" % dt_to_filetime(datetime(2009, 7, 25, 23, 0))
45
+ '128930364000000000'
46
+
47
+ >>> "%.0f" % dt_to_filetime(datetime(1970, 1, 1, 0, 0, tzinfo=UTC))
48
+ '116444736000000000'
49
+
50
+ >>> "%.0f" % dt_to_filetime(datetime(1970, 1, 1, 0, 0))
51
+ '116444736000000000'
52
+
53
+ >>> dt_to_filetime(datetime(2009, 7, 25, 23, 0, 0, 100))
54
+ 128930364000001000
55
+ """
56
+ if (dt.tzinfo is None) or (dt.tzinfo.utcoffset(dt) is None):
57
+ dt = dt.replace(tzinfo=UTC)
58
+ ft = EPOCH_AS_FILETIME + (timegm(dt.timetuple()) * HUNDREDS_OF_NANOSECONDS)
59
+ return ft + (dt.microsecond * 10)
60
+
61
+
62
+ def filetime_to_dt(ft: int) -> datetime:
63
+ """Converts a Microsoft filetime number to a Python datetime. The new
64
+ datetime object is time zone-naive but is equivalent to tzinfo=utc.
65
+
66
+ >>> filetime_to_dt(116444736000000000)
67
+ datetime.datetime(1970, 1, 1, 0, 0)
68
+
69
+ >>> filetime_to_dt(128930364000000000)
70
+ datetime.datetime(2009, 7, 25, 23, 0)
71
+
72
+ >>> filetime_to_dt(128930364000001000)
73
+ datetime.datetime(2009, 7, 25, 23, 0, 0, 100)
74
+ """
75
+ # Get seconds and remainder in terms of Unix epoch
76
+ (s, ns100) = divmod(ft - EPOCH_AS_FILETIME, HUNDREDS_OF_NANOSECONDS)
77
+ # Convert to datetime object
78
+ dt = datetime.fromtimestamp(s, UTC)
79
+ # Add remainder in as microseconds. Python 3.2 requires an integer
80
+ dt = dt.replace(microsecond=(ns100 // 10))
81
+ return dt
@@ -0,0 +1,34 @@
1
+ """Base Models."""
2
+
3
+ from pydantic import ConfigDict, BaseModel
4
+
5
+
6
+ def to_pascal(string: str) -> str:
7
+ return "".join(word.capitalize() for word in string.split("_"))
8
+
9
+
10
+ def to_camel(string: str) -> str:
11
+ words = string.split("_")
12
+ return words[0] + "".join(word.capitalize() for word in words[1:])
13
+
14
+
15
+ def to_lower(string: str) -> str:
16
+ return string.replace("_", "")
17
+
18
+
19
+ class PascalCaseModel(BaseModel):
20
+ model_config = ConfigDict(
21
+ arbitrary_types_allowed=True, populate_by_name=True, alias_generator=to_pascal
22
+ )
23
+
24
+
25
+ class CamelCaseModel(BaseModel):
26
+ model_config = ConfigDict(
27
+ arbitrary_types_allowed=True, populate_by_name=True, alias_generator=to_camel
28
+ )
29
+
30
+
31
+ class LowerCaseModel(BaseModel):
32
+ model_config = ConfigDict(
33
+ arbitrary_types_allowed=True, populate_by_name=True, alias_generator=to_lower
34
+ )