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