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,268 @@
1
+ from abc import ABCMeta, abstractmethod
2
+ from datetime import datetime, timedelta
3
+
4
+ from pythonxbox.common.ratelimits.models import (
5
+ IncrementResult,
6
+ LimitType,
7
+ ParsedRateLimit,
8
+ TimePeriod,
9
+ )
10
+
11
+
12
+ class RateLimit(metaclass=ABCMeta):
13
+ """
14
+ Abstract class for varying implementations/types of rate limits.
15
+ All methods in this class are overriden in every implementation.
16
+ However, different implementations may have additional functions not present in this parent abstract class.
17
+
18
+ A class implementing RateLimit functions without any external threads.
19
+ When the first increment request is recieved (after a counter reset or a new instaniciation)
20
+ a reset_after variable is set detailing when the rate limit(s) reset.
21
+
22
+ Upon each function invokation, the reset_after variable is checked and the timer is automatically reset if the reset_after time has passed.
23
+ """
24
+
25
+ @abstractmethod
26
+ def get_counter(self) -> int:
27
+ # Docstrings are defined in child classes due to their differing implementations.
28
+ pass
29
+
30
+ @abstractmethod
31
+ def get_reset_after(self) -> datetime | None:
32
+ # Docstrings are defined in child classes due to their differing implementations.
33
+ pass
34
+
35
+ @abstractmethod
36
+ def is_exceeded(self) -> bool:
37
+ # Docstrings are defined in child classes due to their differing implementations.
38
+ pass
39
+
40
+ @abstractmethod
41
+ def increment(self) -> IncrementResult:
42
+ """
43
+ The increment function adds one to the rate limit request counter.
44
+
45
+ If the reset_after time has passed, the counter will first be reset before counting the request.
46
+
47
+ When the counter hits 1, the reset_after time is calculated and stored.
48
+
49
+ This function returns an `IncrementResult` object, containing the keys `counter: int` and `exceeded: bool`.
50
+ This can be used by the caller to determine the current state of the rate-limit object without making an additional function call.
51
+ """
52
+
53
+ pass
54
+
55
+
56
+ class SingleRateLimit(RateLimit):
57
+ """
58
+ A rate limit implementation for a single rate limit, such as a burst or sustain limit.
59
+ This class is mainly used by the CombinedRateLimit class.
60
+ """
61
+
62
+ def __init__(self, time_period: TimePeriod, type: LimitType, limit: int) -> None:
63
+ self.__time_period = time_period
64
+ self.__type = type
65
+ self.__limit = limit
66
+
67
+ self.__exceeded: bool = False
68
+ self.__counter = 0
69
+ # No requests so far, so reset_after is None.
70
+ self.__reset_after: datetime | None = None
71
+
72
+ def get_counter(self) -> int:
73
+ """
74
+ This function returns the current request counter variable.
75
+ """
76
+
77
+ return self.__counter
78
+
79
+ def get_time_period(self) -> "TimePeriod":
80
+ return self.__time_period
81
+
82
+ def get_limit(self) -> int:
83
+ return self.__limit
84
+
85
+ def get_limit_type(self) -> "LimitType":
86
+ return self.__type
87
+
88
+ def get_reset_after(self) -> datetime | None:
89
+ """
90
+ This getter returns the current state of the reset_after counter.
91
+
92
+ If the counter in use, it's corresponding `datetime` object is returned.
93
+
94
+ If the counter is not in use, `None` is returned.
95
+ """
96
+
97
+ return self.__reset_after
98
+
99
+ def is_exceeded(self) -> bool:
100
+ """
101
+ This functions returns `True` if the rate limit has been exceeded.
102
+ """
103
+
104
+ self.__reset_counter_if_required()
105
+ return self.__exceeded
106
+
107
+ def increment(self) -> IncrementResult:
108
+ # Call a function to check if the counter should be reset
109
+ self.__reset_counter_if_required()
110
+
111
+ # Increment the counter
112
+ self.__counter += 1
113
+
114
+ # If the counter is 1, (first request after a reset) set the reset_after value.
115
+ if self.__counter == 1:
116
+ self.__set_reset_after()
117
+
118
+ # Check to see if we have now exceeded the request limit
119
+ self.__check_if_exceeded()
120
+
121
+ # Return an instance of IncrementResult
122
+ return IncrementResult(counter=self.__counter, exceeded=self.__exceeded)
123
+
124
+ # Should be called after every inc of the counter
125
+ def __check_if_exceeded(self) -> None:
126
+ if not self.__exceeded:
127
+ if self.__counter >= self.__limit:
128
+ self.__exceeded = True
129
+ # reset-after is now dependent on the time since the first request of this cycle.
130
+ # self.__set_reset_after()
131
+
132
+ def __reset_counter_if_required(self) -> None:
133
+ # Check to make sure reset_after is not None
134
+ # - This is the case if this function is called before the counter
135
+ # is incremented after a reset / new instantiation
136
+ if self.__reset_after is not None:
137
+ if self.__reset_after < datetime.now():
138
+ self.__exceeded = False
139
+ self.__counter = 0
140
+ self.__reset_after = None
141
+
142
+ def __set_reset_after(self) -> None:
143
+ self.__reset_after = datetime.now() + timedelta(
144
+ seconds=self.get_time_period().value
145
+ )
146
+
147
+
148
+ class CombinedRateLimit(RateLimit):
149
+ """
150
+ A rate limit implementation for multiple rate limits, such as burst and sustain.
151
+
152
+ """
153
+
154
+ def __init__(self, *parsed_limits: ParsedRateLimit, type: LimitType) -> None:
155
+ # *parsed_limits is a tuple
156
+
157
+ # Create a SingleRateLimit instance for each limit
158
+ self.__limits: list[SingleRateLimit] = []
159
+
160
+ for limit in parsed_limits:
161
+ # Use the type param (enum LimitType) to determine which limit to select
162
+ limit_num = limit.read if type == LimitType.READ else limit.write
163
+
164
+ # Create a new instance of SingleRateLimit and append it to the limits array.
165
+ srl = SingleRateLimit(limit.period, type, limit_num)
166
+ self.__limits.append(srl)
167
+
168
+ def get_counter(self) -> int:
169
+ """
170
+ This function returns the request counter with the **highest** value.
171
+
172
+ A `CombinedRateLimit` consists of multiple different rate limits, which may have differing counter values.
173
+ """
174
+
175
+ # Map self.__limits to (limit).get_counter()
176
+ counter_map = map(lambda limit: limit.get_counter(), self.__limits)
177
+ counters = list(counter_map)
178
+
179
+ # Sort the counters list by value
180
+ # reverse=True to get highest first
181
+ counters.sort(reverse=True)
182
+
183
+ # Return the highest value
184
+ return counters[0]
185
+
186
+ # We don't want a datetime response for a limit that has not been exceeded.
187
+ # Otherwise eg. 10 burst requests -> 300s timeout (should be 30 (burst exceeded), 300s (not exceeded)
188
+ def get_reset_after(self) -> datetime | None:
189
+ """
190
+ This getter returns either a `datetime` object or `None` object depending on the status of the rate limit.
191
+
192
+ If the counter is in use, the rate limit with the **latest** reset_after is returned.
193
+
194
+ This is so that this function can reliably be used as a indicator of when all rate limits have been reset.
195
+
196
+ If the counter is not in use, `None` is returned.
197
+ """
198
+
199
+ # Get a list of limits that *have been exceeded*
200
+ dates_exceeded_only = filter(lambda limit: limit.is_exceeded(), self.__limits)
201
+
202
+ # Map self.__limits to (limit).get_reset_after()
203
+ dates_map = map(lambda limit: limit.get_reset_after(), dates_exceeded_only)
204
+
205
+ # Convert the map object to a list
206
+ dates = list(dates_map)
207
+
208
+ # Construct a new list with only elements of instance datetime
209
+ # (Effectively filtering out any None elements)
210
+ dates_valid = [elem for elem in dates if isinstance(elem, datetime)]
211
+
212
+ # If dates_valid has any elements, return the one with the *later* timestamp.
213
+ # This means that if two or more limits have been exceeded, we wait for both to have reset (by returning the later timestamp)
214
+ if len(dates_valid) != 0:
215
+ # By default dates are sorted with the earliest date first.
216
+ # We will set reverse=True so that the first element is the later date.
217
+ dates_valid.sort(reverse=True)
218
+
219
+ # Return the datetime object.
220
+ return dates_valid[0]
221
+
222
+ # dates_valid has no elements, return None
223
+ return None
224
+
225
+ # list -> List (typing.List) https://stackoverflow.com/a/63460173
226
+ def get_limits(self) -> list[SingleRateLimit]:
227
+ return self.__limits
228
+
229
+ # list -> List (typing.List) https://stackoverflow.com/a/63460173
230
+ def get_limits_by_period(self, period: TimePeriod) -> list[SingleRateLimit]:
231
+ # Filter the list for the given LimitType
232
+ matches = filter(lambda limit: limit.get_time_period() == period, self.__limits)
233
+ # Convert the filter object to a list and return it
234
+ return list(matches)
235
+
236
+ def is_exceeded(self) -> bool:
237
+ """
238
+ This function returns `True` if **any** rate limit has been exceeded.
239
+
240
+ It behaves like an OR logic gate.
241
+ """
242
+
243
+ # Map self.__limits to (limit).is_exceeded()
244
+ is_exceeded_map = map(lambda limit: limit.is_exceeded(), self.__limits)
245
+ is_exceeded_list = list(is_exceeded_map)
246
+
247
+ # Return True if any variable in list is True
248
+ return True in is_exceeded_list
249
+
250
+ def increment(self) -> IncrementResult:
251
+ # Increment each limit
252
+ results: list[IncrementResult] = []
253
+ for limit in self.__limits:
254
+ result = limit.increment()
255
+ results.append(result)
256
+
257
+ # SPEC: Let's pick the *higher* counter
258
+ # By default, sorted() returns in ascending order, so let's set reverse=True
259
+ # This means that the result with the highest counter will be the first element.
260
+ results_sorted = sorted(results, key=lambda i: i.counter, reverse=True)
261
+
262
+ # Create an instance of IncrementResult and return it.
263
+ return IncrementResult(
264
+ counter=results_sorted[
265
+ 0
266
+ ].counter, # Use the highest counter (sorted in descending order)
267
+ exceeded=self.is_exceeded(), # Call self.is_exceeded (True if any limit has been exceeded, like an OR gate.)
268
+ )
@@ -0,0 +1,23 @@
1
+ from enum import Enum
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class TimePeriod(Enum):
6
+ BURST = 15 # 15 seconds
7
+ SUSTAIN = 300 # 5 minutes (300s)
8
+
9
+
10
+ class LimitType(Enum):
11
+ WRITE = 0
12
+ READ = 1
13
+
14
+
15
+ class IncrementResult(BaseModel):
16
+ counter: int
17
+ exceeded: bool
18
+
19
+
20
+ class ParsedRateLimit(BaseModel):
21
+ read: int
22
+ write: int
23
+ period: TimePeriod
@@ -0,0 +1,190 @@
1
+ """
2
+ Request Signer
3
+
4
+ Employed for generating the "Signature" header in authentication requests.
5
+ """
6
+
7
+ import base64
8
+ from datetime import datetime, UTC
9
+ import hashlib
10
+ import struct
11
+
12
+ from ecdsa import NIST256p, SigningKey, VerifyingKey
13
+
14
+ from pythonxbox.authentication.models import SignaturePolicy
15
+ from pythonxbox.common import filetimes
16
+
17
+ DEFAULT_SIGNING_POLICY = SignaturePolicy(
18
+ version=1, supported_algorithms=["ES256"], max_body_bytes=8192
19
+ )
20
+
21
+
22
+ class RequestSigner:
23
+ def __init__(
24
+ self,
25
+ signing_key: SigningKey | None = None,
26
+ signing_policy: SignaturePolicy | None = None,
27
+ ) -> None:
28
+ self.signing_key = signing_key or SigningKey.generate(curve=NIST256p)
29
+ self.signing_policy = signing_policy or DEFAULT_SIGNING_POLICY
30
+
31
+ pk_point = self.signing_key.verifying_key.pubkey.point
32
+ self.proof_field = {
33
+ "use": "sig",
34
+ "alg": self.signing_policy.supported_algorithms[0],
35
+ "kty": "EC",
36
+ "crv": "P-256",
37
+ "x": self.__encode_ec_coord(pk_point.x()),
38
+ "y": self.__encode_ec_coord(pk_point.y()),
39
+ }
40
+
41
+ def export_signing_key(self) -> str:
42
+ return self.signing_key.to_pem().decode()
43
+
44
+ @staticmethod
45
+ def import_signing_key(signing_key: str) -> SigningKey:
46
+ return SigningKey.from_pem(signing_key)
47
+
48
+ @classmethod
49
+ def from_pem(cls, pem_string: str) -> SigningKey:
50
+ request_signer = RequestSigner.import_signing_key(pem_string)
51
+ return cls(request_signer)
52
+
53
+ @staticmethod
54
+ def get_timestamp_buffer(dt: datetime) -> bytes:
55
+ """
56
+ Get usable buffer from datetime
57
+
58
+ dt: Input datetime
59
+
60
+ Returns:
61
+ bytes: FILETIME buffer (network order/big endian)
62
+ """
63
+ filetime = filetimes.dt_to_filetime(dt)
64
+ return struct.pack("!Q", filetime)
65
+
66
+ @staticmethod
67
+ def get_signature_version_buffer(version: int) -> bytes:
68
+ """
69
+ Get big endian uint32 bytes-representation from
70
+ signature version
71
+
72
+ version: Signature version
73
+
74
+ Returns: Version as uint32 big endian bytes
75
+ """
76
+ return struct.pack("!I", version)
77
+
78
+ def verify_digest(
79
+ self,
80
+ signature: bytes,
81
+ digest: bytes,
82
+ verifying_key: VerifyingKey | None = None,
83
+ ) -> bool:
84
+ """
85
+ Verify signature against digest
86
+
87
+ signature: Signature to validate
88
+ message: Digest to verify
89
+ verifying_key: Public key to use for verification.
90
+ If that key is not provided, the private key used for signing is used.
91
+
92
+ Returns: True on successful verification, False otherwise
93
+ """
94
+ verifier = verifying_key or self.signing_key.verifying_key
95
+ return verifier.verify_digest(signature, digest)
96
+
97
+ def sign(
98
+ self,
99
+ method: str,
100
+ path_and_query: str,
101
+ body: bytes = b"",
102
+ authorization: str = "",
103
+ timestamp: datetime = None,
104
+ ) -> str:
105
+ if timestamp is None:
106
+ timestamp = datetime.now(UTC)
107
+
108
+ signature = self._sign_raw(
109
+ method, path_and_query, body, authorization, timestamp
110
+ )
111
+ return base64.b64encode(signature).decode("ascii")
112
+
113
+ def _sign_raw(
114
+ self,
115
+ method: str,
116
+ path_and_query: str,
117
+ body: bytes,
118
+ authorization: str,
119
+ timestamp: datetime,
120
+ ) -> bytes:
121
+ # Get big-endian representation of signature version and timestamp (FILETIME)
122
+ signature_version_bytes = self.get_signature_version_buffer(
123
+ self.signing_policy.version
124
+ )
125
+ ts_bytes = self.get_timestamp_buffer(timestamp)
126
+
127
+ # Concatenate bytes to sign + hash
128
+ data = self._concat_data_to_sign(
129
+ signature_version_bytes,
130
+ method,
131
+ path_and_query,
132
+ body,
133
+ authorization,
134
+ ts_bytes,
135
+ self.signing_policy.max_body_bytes,
136
+ )
137
+
138
+ # Calculate digest
139
+ digest = self._hash(data)
140
+
141
+ # Sign the hash
142
+ signature = self.signing_key.sign_digest_deterministic(digest)
143
+
144
+ # Return signature version + timestamp encoded + signature
145
+ return signature_version_bytes + ts_bytes + signature
146
+
147
+ @staticmethod
148
+ def _hash(data: bytes) -> bytes:
149
+ hash = hashlib.sha256()
150
+ hash.update(data)
151
+ return hash.digest()
152
+
153
+ @staticmethod
154
+ def _concat_data_to_sign(
155
+ signature_version: bytes,
156
+ method: str,
157
+ path_and_query: str,
158
+ body: bytes,
159
+ authorization: str,
160
+ ts_bytes: bytes,
161
+ max_body_bytes: int,
162
+ ) -> bytes:
163
+ body_size_to_hash = min(len(body), max_body_bytes)
164
+
165
+ return (
166
+ signature_version
167
+ + b"\x00"
168
+ + ts_bytes
169
+ + b"\x00"
170
+ + method.upper().encode("ascii")
171
+ + b"\x00"
172
+ + path_and_query.encode("ascii")
173
+ + b"\x00"
174
+ + authorization.encode("ascii")
175
+ + b"\x00"
176
+ + body[:body_size_to_hash]
177
+ + b"\x00"
178
+ )
179
+
180
+ @staticmethod
181
+ def __base64_escaped(binary: bytes) -> str:
182
+ encoded = base64.b64encode(binary).decode("ascii")
183
+ encoded = encoded.rstrip("=")
184
+ encoded = encoded.replace("+", "-")
185
+ encoded = encoded.replace("/", "_")
186
+ return encoded
187
+
188
+ @staticmethod
189
+ def __encode_ec_coord(coord: int) -> str:
190
+ return RequestSigner.__base64_escaped(coord.to_bytes(32, "big"))
@@ -0,0 +1,60 @@
1
+ """
2
+ Signed Session
3
+ A wrapper around httpx' AsyncClient which transparently calculates the "Signature" header.
4
+ """
5
+
6
+ from ssl import SSLContext
7
+
8
+ import httpx
9
+ from httpx import Response
10
+
11
+ from pythonxbox.common.request_signer import RequestSigner
12
+
13
+
14
+ class SignedSession(httpx.AsyncClient):
15
+ def __init__(
16
+ self,
17
+ request_signer: RequestSigner | None = None,
18
+ ssl_context: SSLContext | None = None,
19
+ ) -> None:
20
+ super().__init__(verify=ssl_context if ssl_context is not None else True)
21
+
22
+ self.request_signer = request_signer or RequestSigner()
23
+
24
+ @classmethod
25
+ def from_pem_signing_key(cls, pem_string: str) -> "SignedSession":
26
+ request_signer = RequestSigner.from_pem(pem_string)
27
+ return cls(request_signer)
28
+
29
+ def _prepare_signed_request(self, request: httpx.Request) -> httpx.Request:
30
+ path_and_query = request.url.raw_path.decode()
31
+ authorization = request.headers.get("Authorization", "")
32
+
33
+ body = b""
34
+ for byte in request.stream:
35
+ body += byte
36
+
37
+ signature = self.request_signer.sign(
38
+ method=request.method,
39
+ path_and_query=path_and_query,
40
+ body=body,
41
+ authorization=authorization,
42
+ )
43
+
44
+ request.headers["Signature"] = signature
45
+ return request
46
+
47
+ async def send_request_signed(self, request: httpx.Request) -> httpx.Response:
48
+ """
49
+ Shorthand for prepare signed + send
50
+ """
51
+ prepared = self._prepare_signed_request(request)
52
+ return await self.send(prepared)
53
+
54
+ async def send_signed(self, method: str, url: str, **kwargs) -> Response:
55
+ """
56
+ Shorthand for creating request + prepare signed + send
57
+ """
58
+ request = httpx.Request(method, url, **kwargs)
59
+ prepared = self._prepare_signed_request(request)
60
+ return await self.send(prepared)
pythonxbox/py.typed ADDED
File without changes
@@ -0,0 +1,15 @@
1
+ import os
2
+
3
+ from platformdirs import user_data_dir
4
+
5
+ CLIENT_ID = "388ea51c-0b25-4029-aae2-17df49d23905"
6
+ # No secret needed, we registered as "Desktop App" in Azure AD
7
+ CLIENT_SECRET = ""
8
+ REDIRECT_URI = "http://localhost:8080/auth/callback"
9
+
10
+ DATA_DIR = user_data_dir("xbox", "OpenXbox")
11
+ TOKENS_FILE = os.path.join(DATA_DIR, "tokens.json")
12
+ XAL_TOKENS_FILE = os.path.join(DATA_DIR, "xal_tokens.json")
13
+
14
+ if not os.path.exists(DATA_DIR):
15
+ os.makedirs(DATA_DIR)