runtilities 2.3.0.dev2__tar.gz → 3.0.0__tar.gz
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.
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/PKG-INFO +1 -1
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runsignup.py +92 -146
- runtilities-3.0.0/running/runsignup_fluent.py +36 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/version.py +1 -1
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/PKG-INFO +1 -1
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/SOURCES.txt +5 -1
- runtilities-3.0.0/tests/test_runsignup.py +154 -0
- runtilities-3.0.0/tests/test_runsignup_fluent.py +80 -0
- runtilities-3.0.0/tests/test_runsignup_live.py +117 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/README.md +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/__init__.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/analyzeagegrade.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/athlinks.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/athlinksresults.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/comparegpx.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/competitor.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/competitor2csv.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/getweather.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/gpx2kml.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parseralogxml.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parseresults.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parserun-bk3.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parsetcx.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/ra2membersfile.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/racewx-render.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/racewx.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/renderclubagstats.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningahead.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningaheadmembers.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningaheadparticipants.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningaheadresults.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/strava.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/textnormalize.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/ultrasignup.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/ultrasignupresults.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmldict.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmldict1.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmldict2.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmlreader.py +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/dependency_links.txt +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/entry_points.txt +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/not-zip-safe +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/requires.txt +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/top_level.txt +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/setup.cfg +0 -0
- {runtilities-2.3.0.dev2 → runtilities-3.0.0}/setup.py +0 -0
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
###########################################################################################
|
|
2
|
-
# runsignup - access methods for runsignup.com
|
|
3
|
-
#
|
|
4
|
-
# Date Author Reason
|
|
5
|
-
# ---- ------ ------
|
|
6
|
-
# 12/31/17 Lou King Create from runningahead.com
|
|
7
|
-
#
|
|
8
|
-
# Copyright 2017 Lou King
|
|
9
|
-
#
|
|
10
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
11
|
-
# you may not use this file except in compliance with the License.
|
|
12
|
-
# You may obtain a copy of the License at
|
|
13
|
-
#
|
|
14
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
15
|
-
#
|
|
16
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
17
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
18
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
19
|
-
# See the License for the specific language governing permissions and
|
|
20
|
-
# limitations under the License.
|
|
21
|
-
#
|
|
22
|
-
###########################################################################################
|
|
23
1
|
'''
|
|
24
2
|
runsignup - access methods for runsignup.com
|
|
25
3
|
===================================================
|
|
@@ -37,7 +15,6 @@ from tempfile import NamedTemporaryFile
|
|
|
37
15
|
# pypi
|
|
38
16
|
from flask import current_app
|
|
39
17
|
import requests
|
|
40
|
-
from universalclient import Client as UniversalClient
|
|
41
18
|
|
|
42
19
|
# github
|
|
43
20
|
|
|
@@ -52,10 +29,6 @@ names = NameDenormalizer()
|
|
|
52
29
|
|
|
53
30
|
# use api.runsignup.com per https://info.runsignup.com/2025/08/06/upgrading-our-api-infrastructure-for-ai-api-runsignup-com/
|
|
54
31
|
|
|
55
|
-
# login API (deprecated)
|
|
56
|
-
login_url = 'https://api.runsignup.com/rest/login'
|
|
57
|
-
logout_url = 'https://api.runsignup.com/rest/logout'
|
|
58
|
-
|
|
59
32
|
# methods
|
|
60
33
|
members_url = 'https://api.runsignup.com/rest/club/{club_id}/members'
|
|
61
34
|
getrace_url = 'https://api.runsignup.com/rest/race/{race_id}'
|
|
@@ -74,21 +47,25 @@ class parameterError(Exception): pass
|
|
|
74
47
|
thislogger = logging.getLogger("running.runsignup")
|
|
75
48
|
|
|
76
49
|
########################################################################
|
|
77
|
-
class
|
|
50
|
+
class RunSignupBase():
|
|
78
51
|
'''
|
|
79
|
-
|
|
52
|
+
common session / authentication / low-level request handling for RunSignUp.com REST API clients
|
|
80
53
|
|
|
81
|
-
|
|
82
|
-
|
|
54
|
+
key and secret should be supplied for authenticated access
|
|
55
|
+
|
|
56
|
+
per https://info.runsignup.com/2026/07/17/new-api-registration-requirements/, all API callers must
|
|
57
|
+
register (free) and supply the resulting token/secret on every call starting 2027-01-01; existing
|
|
58
|
+
api_key/api_secret credentials are unaffected and continue to be required alongside these
|
|
83
59
|
|
|
84
60
|
:param key: key from runsignup (direct key, no OAuth)
|
|
85
61
|
:param secret: secret from runsignup (direct secret, no OAuth)
|
|
86
|
-
:param
|
|
87
|
-
:param
|
|
62
|
+
:param api_reg_token: API caller registration token, sent as rsu_api_reg GET parameter
|
|
63
|
+
:param api_reg_secret: API caller registration secret, sent as X-RSU-API-REG-SECRET header
|
|
88
64
|
:param debug: set to True for debug logging of http requests, default False
|
|
89
65
|
'''
|
|
90
66
|
|
|
91
|
-
def __init__(self, userpriv=False, key=None, secret=None,
|
|
67
|
+
def __init__(self, userpriv=False, key=None, secret=None,
|
|
68
|
+
api_reg_token=None, api_reg_secret=None, debug=False):
|
|
92
69
|
"""
|
|
93
70
|
initialize
|
|
94
71
|
"""
|
|
@@ -108,27 +85,20 @@ class RunSignUp():
|
|
|
108
85
|
requests_log.setLevel(logging.NOTSET)
|
|
109
86
|
requests_log.propagate = False
|
|
110
87
|
|
|
111
|
-
self.userpriv =
|
|
112
|
-
if (not key and not email):
|
|
113
|
-
self.userpriv = True
|
|
88
|
+
self.userpriv = not key
|
|
114
89
|
if (key and not secret) or (secret and not key):
|
|
115
90
|
raise parameterError('key and secret must be supplied together')
|
|
116
|
-
if (
|
|
117
|
-
raise parameterError('
|
|
91
|
+
if (api_reg_token and not api_reg_secret) or (api_reg_secret and not api_reg_token):
|
|
92
|
+
raise parameterError('api_reg_token and api_reg_secret must be supplied together')
|
|
118
93
|
|
|
119
94
|
self.key = key
|
|
120
95
|
self.secret = secret
|
|
121
|
-
self.
|
|
122
|
-
self.
|
|
96
|
+
self.api_reg_token = api_reg_token
|
|
97
|
+
self.api_reg_secret = api_reg_secret
|
|
123
98
|
self.debug = debug
|
|
124
99
|
self.client_credentials = {}
|
|
125
100
|
|
|
126
|
-
if self.userpriv
|
|
127
|
-
self.credentials_type = 'none'
|
|
128
|
-
elif key:
|
|
129
|
-
self.credentials_type = 'key'
|
|
130
|
-
else:
|
|
131
|
-
self.credentials_type = 'login'
|
|
101
|
+
self.credentials_type = 'none' if self.userpriv else 'key'
|
|
132
102
|
|
|
133
103
|
def __enter__(self):
|
|
134
104
|
self.open()
|
|
@@ -142,22 +112,18 @@ class RunSignUp():
|
|
|
142
112
|
# set up session for multiple requests
|
|
143
113
|
self.session = requests.Session()
|
|
144
114
|
|
|
115
|
+
# per https://info.runsignup.com/2026/07/17/new-api-registration-requirements/, registration
|
|
116
|
+
# secret is sent as a header, applies regardless of credentials_type
|
|
117
|
+
if self.api_reg_secret:
|
|
118
|
+
self.session.headers.update({'X-RSU-API-REG-SECRET': self.api_reg_secret})
|
|
119
|
+
|
|
145
120
|
if not self.userpriv:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
else:
|
|
153
|
-
# login to runsignup - note temporary keys will expire 1 hour after last API call
|
|
154
|
-
# see https://api.runsignup.com/API/login/POST
|
|
155
|
-
r = requests.post(login_url, params={'format' : 'json'}, data={'email' : self.email, 'password' : self.password})
|
|
156
|
-
resp = r.json()
|
|
157
|
-
|
|
158
|
-
self.credentials_type = 'login'
|
|
159
|
-
self.client_credentials = {'tmp_key' : resp['tmp_key'],
|
|
160
|
-
'tmp_secret' : resp['tmp_secret']}
|
|
121
|
+
self.client_credentials = {'api_key' : self.key,
|
|
122
|
+
'api_secret' : self.secret}
|
|
123
|
+
|
|
124
|
+
# registration token is sent as a GET parameter alongside whichever credentials were set above
|
|
125
|
+
if self.api_reg_token:
|
|
126
|
+
self.client_credentials['rsu_api_reg'] = self.api_reg_token
|
|
161
127
|
|
|
162
128
|
def close(self):
|
|
163
129
|
'''
|
|
@@ -166,7 +132,61 @@ class RunSignUp():
|
|
|
166
132
|
self.client_credentials = {}
|
|
167
133
|
self.session.close()
|
|
168
134
|
|
|
169
|
-
|
|
135
|
+
def _rsugetcsv(self, methodurl, **payload):
|
|
136
|
+
"""
|
|
137
|
+
get method for runsignup access (csv format response)
|
|
138
|
+
|
|
139
|
+
:param methodurl: runsignup method url to call
|
|
140
|
+
:param contentfield: content field to retrieve from response
|
|
141
|
+
:param **payload: parameters for the method
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
thispayload = self.client_credentials.copy()
|
|
145
|
+
thispayload.update(payload)
|
|
146
|
+
thispayload.update({'format':'csv'})
|
|
147
|
+
|
|
148
|
+
resp = self.session.get(methodurl, params=thispayload)
|
|
149
|
+
if resp.status_code != 200:
|
|
150
|
+
raise accessError('HTTP response code={}, url={}'.format(resp.status_code,resp.url))
|
|
151
|
+
|
|
152
|
+
data = resp.text
|
|
153
|
+
|
|
154
|
+
# if 'error' in data:
|
|
155
|
+
# raise accessError('RSU response code={}-{}, url={}'.format(data['error']['error_code'],data['error']['error_msg'],resp.url))
|
|
156
|
+
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
def _rsuget(self, methodurl, **payload):
|
|
160
|
+
"""
|
|
161
|
+
get method for runsignup access (json format response)
|
|
162
|
+
|
|
163
|
+
:param methodurl: runsignup method url to call
|
|
164
|
+
:param contentfield: content field to retrieve from response
|
|
165
|
+
:param **payload: parameters for the method
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
thispayload = self.client_credentials.copy()
|
|
169
|
+
thispayload.update(payload)
|
|
170
|
+
thispayload.update({'format':'json'})
|
|
171
|
+
|
|
172
|
+
resp = self.session.get(methodurl, params=thispayload)
|
|
173
|
+
if resp.status_code != 200:
|
|
174
|
+
raise accessError('HTTP response code={}, url={}'.format(resp.status_code,resp.url))
|
|
175
|
+
|
|
176
|
+
data = resp.json()
|
|
177
|
+
|
|
178
|
+
if 'error' in data:
|
|
179
|
+
raise accessError('RSU response code={}-{}, url={}'.format(data['error']['error_code'],data['error']['error_msg'],resp.url))
|
|
180
|
+
|
|
181
|
+
return data
|
|
182
|
+
|
|
183
|
+
########################################################################
|
|
184
|
+
class RunSignUp(RunSignupBase):
|
|
185
|
+
'''
|
|
186
|
+
access methods for RunSignUp.com
|
|
187
|
+
|
|
188
|
+
see :class:`RunSignupBase` for authentication / session parameters
|
|
189
|
+
'''
|
|
170
190
|
|
|
171
191
|
def members(self, club_id, **kwargs):
|
|
172
192
|
'''
|
|
@@ -371,56 +391,9 @@ class RunSignUp():
|
|
|
371
391
|
|
|
372
392
|
return '\n'.join([header] + results)
|
|
373
393
|
|
|
374
|
-
def _rsugetcsv(self, methodurl, **payload):
|
|
375
|
-
"""
|
|
376
|
-
get method for runsignup access (csv format response)
|
|
377
|
-
|
|
378
|
-
:param methodurl: runsignup method url to call
|
|
379
|
-
:param contentfield: content field to retrieve from response
|
|
380
|
-
:param **payload: parameters for the method
|
|
381
|
-
"""
|
|
382
|
-
|
|
383
|
-
thispayload = self.client_credentials.copy()
|
|
384
|
-
thispayload.update(payload)
|
|
385
|
-
thispayload.update({'format':'csv'})
|
|
386
|
-
|
|
387
|
-
resp = self.session.get(methodurl, params=thispayload)
|
|
388
|
-
if resp.status_code != 200:
|
|
389
|
-
raise accessError('HTTP response code={}, url={}'.format(resp.status_code,resp.url))
|
|
390
|
-
|
|
391
|
-
data = resp.text
|
|
392
|
-
|
|
393
|
-
# if 'error' in data:
|
|
394
|
-
# raise accessError('RSU response code={}-{}, url={}'.format(data['error']['error_code'],data['error']['error_msg'],resp.url))
|
|
395
|
-
|
|
396
|
-
return data
|
|
397
|
-
|
|
398
|
-
def _rsuget(self, methodurl, **payload):
|
|
399
|
-
"""
|
|
400
|
-
get method for runsignup access (json format response)
|
|
401
|
-
|
|
402
|
-
:param methodurl: runsignup method url to call
|
|
403
|
-
:param contentfield: content field to retrieve from response
|
|
404
|
-
:param **payload: parameters for the method
|
|
405
|
-
"""
|
|
406
|
-
|
|
407
|
-
thispayload = self.client_credentials.copy()
|
|
408
|
-
thispayload.update(payload)
|
|
409
|
-
thispayload.update({'format':'json'})
|
|
410
|
-
|
|
411
|
-
resp = self.session.get(methodurl, params=thispayload)
|
|
412
|
-
if resp.status_code != 200:
|
|
413
|
-
raise accessError('HTTP response code={}, url={}'.format(resp.status_code,resp.url))
|
|
414
|
-
|
|
415
|
-
data = resp.json()
|
|
416
|
-
|
|
417
|
-
if 'error' in data:
|
|
418
|
-
raise accessError('RSU response code={}-{}, url={}'.format(data['error']['error_code'],data['error']['error_msg'],resp.url))
|
|
419
|
-
|
|
420
|
-
return data
|
|
421
|
-
|
|
422
394
|
#----------------------------------------------------------------------
|
|
423
|
-
def updatemembercache(club_id, membercachefilename, key=None, secret=None,
|
|
395
|
+
def updatemembercache(club_id, membercachefilename, key=None, secret=None,
|
|
396
|
+
api_reg_token=None, api_reg_secret=None, debug=False):
|
|
424
397
|
if debug:
|
|
425
398
|
# set up debug logging
|
|
426
399
|
thislogger.setLevel(logging.DEBUG)
|
|
@@ -431,7 +404,8 @@ def updatemembercache(club_id, membercachefilename, key=None, secret=None, email
|
|
|
431
404
|
thislogger.propagate = True
|
|
432
405
|
|
|
433
406
|
# set up access to RunSignUp
|
|
434
|
-
rsu = RunSignUp(key=key, secret=secret,
|
|
407
|
+
rsu = RunSignUp(key=key, secret=secret,
|
|
408
|
+
api_reg_token=api_reg_token, api_reg_secret=api_reg_secret, debug=debug)
|
|
435
409
|
rsu.open()
|
|
436
410
|
|
|
437
411
|
# transform from RunSignUp to membercache format
|
|
@@ -608,7 +582,8 @@ def updatemembercache(club_id, membercachefilename, key=None, secret=None, email
|
|
|
608
582
|
return rsumembers
|
|
609
583
|
|
|
610
584
|
#----------------------------------------------------------------------
|
|
611
|
-
def members2csv(club_id, key, secret, mapping, filepath=None, encoding=None
|
|
585
|
+
def members2csv(club_id, key, secret, mapping, filepath=None, encoding=None,
|
|
586
|
+
api_reg_token=None, api_reg_secret=None):
|
|
612
587
|
'''
|
|
613
588
|
Access club_id through RunSignUp API to retrieve members. Return
|
|
614
589
|
list of members as if csv file. Optionally save csv file.
|
|
@@ -619,11 +594,13 @@ def members2csv(club_id, key, secret, mapping, filepath=None, encoding=None):
|
|
|
619
594
|
:param mapping: OrderedDict {'outfield1':'infield1', 'outfield2':outfunction(inrec), ...} or ['inoutfield1', 'inoutfield2', ...]
|
|
620
595
|
:param normfile: (optional) pathname to save csv file
|
|
621
596
|
:param encoding: (optional) encoding to use for file
|
|
597
|
+
:param api_reg_token: API caller registration token, sent as rsu_api_reg GET parameter
|
|
598
|
+
:param api_reg_secret: API caller registration secret, sent as X-RSU-API-REG-SECRET header
|
|
622
599
|
:rtype: csv file records, as list
|
|
623
600
|
'''
|
|
624
601
|
|
|
625
602
|
# get the members from the RunSignUp API
|
|
626
|
-
with RunSignUp(key=key, secret=secret) as rsu:
|
|
603
|
+
with RunSignUp(key=key, secret=secret, api_reg_token=api_reg_token, api_reg_secret=api_reg_secret) as rsu:
|
|
627
604
|
members = rsu.members(club_id)
|
|
628
605
|
|
|
629
606
|
filerows = record2csv(members, mapping, filepath, encoding=encoding)
|
|
@@ -873,34 +850,3 @@ class ClubMemberships():
|
|
|
873
850
|
for member in self.userid2mem
|
|
874
851
|
)
|
|
875
852
|
|
|
876
|
-
class RunSignupFluent(UniversalClient):
|
|
877
|
-
from rauth import OAuth2Service
|
|
878
|
-
|
|
879
|
-
'''
|
|
880
|
-
Fluent interface to RunSignUp API -- see https://universal-client.readthedocs.io
|
|
881
|
-
'''
|
|
882
|
-
def __init__(self, key=None, secret=None, debug=False):
|
|
883
|
-
'''
|
|
884
|
-
initialize RunSignUp Fluent client
|
|
885
|
-
|
|
886
|
-
:param key: api key for RunSignUp
|
|
887
|
-
:param secret: api secret for RunSignUp
|
|
888
|
-
:param debug: debug flag
|
|
889
|
-
'''
|
|
890
|
-
# rsuservice = Oauth2Service(
|
|
891
|
-
# name='runsignup',
|
|
892
|
-
# authorize_url='https://www.runsignup.com/oauth2/authorize',
|
|
893
|
-
# access_token_url='https://api.runsignup.com/oauth2/token',
|
|
894
|
-
# client_id=key,
|
|
895
|
-
# client_secret=secret,
|
|
896
|
-
# )
|
|
897
|
-
|
|
898
|
-
self._params = params = {'api_key' : key,
|
|
899
|
-
'api_secret' : secret,
|
|
900
|
-
'format' : 'json' }
|
|
901
|
-
|
|
902
|
-
super().__init__(
|
|
903
|
-
url='https://api.runsignup.com/rest',
|
|
904
|
-
params=params,
|
|
905
|
-
)
|
|
906
|
-
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'''
|
|
2
|
+
runsignup_fluent - fluent access to runsignup.com
|
|
3
|
+
===================================================
|
|
4
|
+
'''
|
|
5
|
+
|
|
6
|
+
# pypi
|
|
7
|
+
from universalclient import Client as UniversalClient
|
|
8
|
+
|
|
9
|
+
class RunSignupFluent(UniversalClient):
|
|
10
|
+
|
|
11
|
+
'''
|
|
12
|
+
Fluent interface to RunSignUp API -- see https://universal-client.readthedocs.io
|
|
13
|
+
'''
|
|
14
|
+
def __init__(self, key=None, secret=None, api_reg_token=None, api_reg_secret=None, debug=False):
|
|
15
|
+
'''
|
|
16
|
+
initialize RunSignUp Fluent client
|
|
17
|
+
|
|
18
|
+
:param key: api key for RunSignUp
|
|
19
|
+
:param secret: api secret for RunSignUp
|
|
20
|
+
:param api_reg_token: API caller registration token, sent as rsu_api_reg GET parameter -- see
|
|
21
|
+
https://info.runsignup.com/2026/07/17/new-api-registration-requirements/
|
|
22
|
+
:param api_reg_secret: API caller registration secret, sent as X-RSU-API-REG-SECRET header
|
|
23
|
+
:param debug: debug flag
|
|
24
|
+
'''
|
|
25
|
+
|
|
26
|
+
self._params = params = {'api_key' : key,
|
|
27
|
+
'api_secret' : secret,
|
|
28
|
+
'format' : 'json' }
|
|
29
|
+
if api_reg_token:
|
|
30
|
+
params['rsu_api_reg'] = api_reg_token
|
|
31
|
+
|
|
32
|
+
client_kwargs = {'url': 'https://api.runsignup.com/rest', 'params': params}
|
|
33
|
+
if api_reg_secret:
|
|
34
|
+
client_kwargs['headers'] = {'X-RSU-API-REG-SECRET': api_reg_secret}
|
|
35
|
+
|
|
36
|
+
super().__init__(**client_kwargs)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# this string is used for the version string in the documentation, as well as the egg
|
|
2
|
-
__version__ = '
|
|
2
|
+
__version__ = '3.0.0'
|
|
@@ -22,6 +22,7 @@ running/runningaheadmembers.py
|
|
|
22
22
|
running/runningaheadparticipants.py
|
|
23
23
|
running/runningaheadresults.py
|
|
24
24
|
running/runsignup.py
|
|
25
|
+
running/runsignup_fluent.py
|
|
25
26
|
running/strava.py
|
|
26
27
|
running/textnormalize.py
|
|
27
28
|
running/ultrasignup.py
|
|
@@ -37,4 +38,7 @@ runtilities.egg-info/dependency_links.txt
|
|
|
37
38
|
runtilities.egg-info/entry_points.txt
|
|
38
39
|
runtilities.egg-info/not-zip-safe
|
|
39
40
|
runtilities.egg-info/requires.txt
|
|
40
|
-
runtilities.egg-info/top_level.txt
|
|
41
|
+
runtilities.egg-info/top_level.txt
|
|
42
|
+
tests/test_runsignup.py
|
|
43
|
+
tests/test_runsignup_fluent.py
|
|
44
|
+
tests/test_runsignup_live.py
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'''
|
|
2
|
+
mocked (no network) tests for running.runsignup.RunSignupBase / RunSignUp
|
|
3
|
+
|
|
4
|
+
covers the RunSignupBase/RunSignUp split and the api_reg_token/api_reg_secret
|
|
5
|
+
wiring required per https://info.runsignup.com/2026/07/17/new-api-registration-requirements/
|
|
6
|
+
'''
|
|
7
|
+
|
|
8
|
+
from urllib.parse import urlparse, parse_qs
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import responses
|
|
12
|
+
|
|
13
|
+
from running.runsignup import RunSignUp, accessError, parameterError
|
|
14
|
+
|
|
15
|
+
KEY = 'testkey'
|
|
16
|
+
SECRET = 'testsecret'
|
|
17
|
+
REG_TOKEN = 'testregtoken'
|
|
18
|
+
REG_SECRET = 'testregsecret'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _qs(url):
|
|
22
|
+
return parse_qs(urlparse(url).query)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestInitValidation:
|
|
26
|
+
def test_key_without_secret(self):
|
|
27
|
+
with pytest.raises(parameterError):
|
|
28
|
+
RunSignUp(key=KEY)
|
|
29
|
+
|
|
30
|
+
def test_secret_without_key(self):
|
|
31
|
+
with pytest.raises(parameterError):
|
|
32
|
+
RunSignUp(secret=SECRET)
|
|
33
|
+
|
|
34
|
+
def test_api_reg_token_without_secret(self):
|
|
35
|
+
with pytest.raises(parameterError):
|
|
36
|
+
RunSignUp(key=KEY, secret=SECRET, api_reg_token=REG_TOKEN)
|
|
37
|
+
|
|
38
|
+
def test_api_reg_secret_without_token(self):
|
|
39
|
+
with pytest.raises(parameterError):
|
|
40
|
+
RunSignUp(key=KEY, secret=SECRET, api_reg_secret=REG_SECRET)
|
|
41
|
+
|
|
42
|
+
def test_no_credentials_is_userpriv(self):
|
|
43
|
+
rsu = RunSignUp()
|
|
44
|
+
assert rsu.userpriv is True
|
|
45
|
+
assert rsu.credentials_type == 'none'
|
|
46
|
+
|
|
47
|
+
def test_key_secret_sets_credentials_type(self):
|
|
48
|
+
rsu = RunSignUp(key=KEY, secret=SECRET)
|
|
49
|
+
assert rsu.userpriv is False
|
|
50
|
+
assert rsu.credentials_type == 'key'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestOpen:
|
|
54
|
+
def test_open_sets_api_key_secret_only(self):
|
|
55
|
+
with RunSignUp(key=KEY, secret=SECRET) as rsu:
|
|
56
|
+
assert rsu.client_credentials == {'api_key': KEY, 'api_secret': SECRET}
|
|
57
|
+
assert 'X-RSU-API-REG-SECRET' not in rsu.session.headers
|
|
58
|
+
|
|
59
|
+
def test_open_sets_api_reg_token_as_param_and_secret_as_header(self):
|
|
60
|
+
with RunSignUp(key=KEY, secret=SECRET, api_reg_token=REG_TOKEN, api_reg_secret=REG_SECRET) as rsu:
|
|
61
|
+
assert rsu.client_credentials == {
|
|
62
|
+
'api_key': KEY, 'api_secret': SECRET, 'rsu_api_reg': REG_TOKEN,
|
|
63
|
+
}
|
|
64
|
+
assert rsu.session.headers['X-RSU-API-REG-SECRET'] == REG_SECRET
|
|
65
|
+
|
|
66
|
+
def test_open_userpriv_with_api_reg_only(self):
|
|
67
|
+
# public endpoint access, but still registered as an API caller
|
|
68
|
+
with RunSignUp(api_reg_token=REG_TOKEN, api_reg_secret=REG_SECRET) as rsu:
|
|
69
|
+
assert rsu.client_credentials == {'rsu_api_reg': REG_TOKEN}
|
|
70
|
+
assert rsu.session.headers['X-RSU-API-REG-SECRET'] == REG_SECRET
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestRsuget:
|
|
74
|
+
@responses.activate
|
|
75
|
+
def test_sends_credentials_and_reg_token_on_request(self):
|
|
76
|
+
race_id = 12345
|
|
77
|
+
responses.add(
|
|
78
|
+
responses.GET,
|
|
79
|
+
f'https://api.runsignup.com/rest/race/{race_id}',
|
|
80
|
+
json={'race': {'race_id': race_id, 'name': 'Test Race'}},
|
|
81
|
+
status=200,
|
|
82
|
+
)
|
|
83
|
+
with RunSignUp(key=KEY, secret=SECRET, api_reg_token=REG_TOKEN, api_reg_secret=REG_SECRET) as rsu:
|
|
84
|
+
race = rsu.getrace(race_id)
|
|
85
|
+
|
|
86
|
+
assert race == {'race_id': race_id, 'name': 'Test Race'}
|
|
87
|
+
sent = responses.calls[0].request
|
|
88
|
+
assert sent.headers['X-RSU-API-REG-SECRET'] == REG_SECRET
|
|
89
|
+
params = _qs(sent.url)
|
|
90
|
+
assert params['api_key'] == [KEY]
|
|
91
|
+
assert params['api_secret'] == [SECRET]
|
|
92
|
+
assert params['rsu_api_reg'] == [REG_TOKEN]
|
|
93
|
+
assert params['format'] == ['json']
|
|
94
|
+
|
|
95
|
+
@responses.activate
|
|
96
|
+
def test_public_endpoint_sends_no_credentials(self):
|
|
97
|
+
race_id = 999
|
|
98
|
+
responses.add(
|
|
99
|
+
responses.GET,
|
|
100
|
+
f'https://api.runsignup.com/rest/race/{race_id}',
|
|
101
|
+
json={'race': {'race_id': race_id}},
|
|
102
|
+
status=200,
|
|
103
|
+
)
|
|
104
|
+
with RunSignUp() as rsu:
|
|
105
|
+
race = rsu.getrace(race_id)
|
|
106
|
+
|
|
107
|
+
assert race == {'race_id': race_id}
|
|
108
|
+
params = _qs(responses.calls[0].request.url)
|
|
109
|
+
assert 'api_key' not in params
|
|
110
|
+
assert 'rsu_api_reg' not in params
|
|
111
|
+
assert 'X-RSU-API-REG-SECRET' not in responses.calls[0].request.headers
|
|
112
|
+
|
|
113
|
+
@responses.activate
|
|
114
|
+
def test_error_response_raises_accesserror(self):
|
|
115
|
+
race_id = 1
|
|
116
|
+
responses.add(
|
|
117
|
+
responses.GET,
|
|
118
|
+
f'https://api.runsignup.com/rest/race/{race_id}',
|
|
119
|
+
json={'error': {'error_code': 100, 'error_msg': 'bad request'}},
|
|
120
|
+
status=200,
|
|
121
|
+
)
|
|
122
|
+
with RunSignUp() as rsu:
|
|
123
|
+
with pytest.raises(accessError):
|
|
124
|
+
rsu.getrace(race_id)
|
|
125
|
+
|
|
126
|
+
@responses.activate
|
|
127
|
+
def test_http_error_raises_accesserror(self):
|
|
128
|
+
race_id = 1
|
|
129
|
+
responses.add(
|
|
130
|
+
responses.GET,
|
|
131
|
+
f'https://api.runsignup.com/rest/race/{race_id}',
|
|
132
|
+
json={},
|
|
133
|
+
status=500,
|
|
134
|
+
)
|
|
135
|
+
with RunSignUp() as rsu:
|
|
136
|
+
with pytest.raises(accessError):
|
|
137
|
+
rsu.getrace(race_id)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestMembersPagination:
|
|
141
|
+
@responses.activate
|
|
142
|
+
def test_paginates_until_short_page(self):
|
|
143
|
+
club_id = 42
|
|
144
|
+
url = f'https://api.runsignup.com/rest/club/{club_id}/members'
|
|
145
|
+
page1 = {'club_members': [{'user': {'user_id': i}} for i in range(100)]}
|
|
146
|
+
page2 = {'club_members': [{'user': {'user_id': 100}}]}
|
|
147
|
+
responses.add(responses.GET, url, json=page1, status=200)
|
|
148
|
+
responses.add(responses.GET, url, json=page2, status=200)
|
|
149
|
+
|
|
150
|
+
with RunSignUp(key=KEY, secret=SECRET) as rsu:
|
|
151
|
+
members = rsu.members(club_id)
|
|
152
|
+
|
|
153
|
+
assert len(members) == 101
|
|
154
|
+
assert len(responses.calls) == 2
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'''
|
|
2
|
+
mocked (no network) tests for running.runsignup_fluent.RunSignupFluent
|
|
3
|
+
|
|
4
|
+
covers the api_reg_token/api_reg_secret wiring required per
|
|
5
|
+
https://info.runsignup.com/2026/07/17/new-api-registration-requirements/
|
|
6
|
+
'''
|
|
7
|
+
|
|
8
|
+
from urllib.parse import urlparse, parse_qs
|
|
9
|
+
|
|
10
|
+
import responses
|
|
11
|
+
|
|
12
|
+
from running.runsignup_fluent import RunSignupFluent
|
|
13
|
+
|
|
14
|
+
KEY = 'testkey'
|
|
15
|
+
SECRET = 'testsecret'
|
|
16
|
+
REG_TOKEN = 'testregtoken'
|
|
17
|
+
REG_SECRET = 'testregsecret'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _qs(url):
|
|
21
|
+
return parse_qs(urlparse(url).query)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestConstructorArgs:
|
|
25
|
+
def test_key_secret_only(self):
|
|
26
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET)
|
|
27
|
+
args = rsu.getArgs()
|
|
28
|
+
assert args['params'] == {'api_key': KEY, 'api_secret': SECRET, 'format': 'json'}
|
|
29
|
+
assert 'headers' not in args
|
|
30
|
+
|
|
31
|
+
def test_api_reg_token_added_to_params(self):
|
|
32
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET, api_reg_token=REG_TOKEN)
|
|
33
|
+
args = rsu.getArgs()
|
|
34
|
+
assert args['params']['rsu_api_reg'] == REG_TOKEN
|
|
35
|
+
assert 'headers' not in args
|
|
36
|
+
|
|
37
|
+
def test_api_reg_secret_added_as_header(self):
|
|
38
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET, api_reg_token=REG_TOKEN, api_reg_secret=REG_SECRET)
|
|
39
|
+
args = rsu.getArgs()
|
|
40
|
+
assert args['headers'] == {'X-RSU-API-REG-SECRET': REG_SECRET}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestRequestWiring:
|
|
44
|
+
@responses.activate
|
|
45
|
+
def test_get_sends_credentials_and_reg_token(self):
|
|
46
|
+
race_id = 12345
|
|
47
|
+
responses.add(
|
|
48
|
+
responses.GET,
|
|
49
|
+
f'https://api.runsignup.com/rest/race/{race_id}',
|
|
50
|
+
json={'race': {'race_id': race_id, 'name': 'Test Race'}},
|
|
51
|
+
status=200,
|
|
52
|
+
)
|
|
53
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET, api_reg_token=REG_TOKEN, api_reg_secret=REG_SECRET)
|
|
54
|
+
resp = rsu.race._(race_id).get()
|
|
55
|
+
|
|
56
|
+
assert resp.json() == {'race': {'race_id': race_id, 'name': 'Test Race'}}
|
|
57
|
+
sent = responses.calls[0].request
|
|
58
|
+
assert sent.headers['X-RSU-API-REG-SECRET'] == REG_SECRET
|
|
59
|
+
params = _qs(sent.url)
|
|
60
|
+
assert params['api_key'] == [KEY]
|
|
61
|
+
assert params['api_secret'] == [SECRET]
|
|
62
|
+
assert params['rsu_api_reg'] == [REG_TOKEN]
|
|
63
|
+
assert params['format'] == ['json']
|
|
64
|
+
|
|
65
|
+
@responses.activate
|
|
66
|
+
def test_get_without_api_reg_sends_no_header(self):
|
|
67
|
+
race_id = 999
|
|
68
|
+
responses.add(
|
|
69
|
+
responses.GET,
|
|
70
|
+
f'https://api.runsignup.com/rest/race/{race_id}',
|
|
71
|
+
json={'race': {'race_id': race_id}},
|
|
72
|
+
status=200,
|
|
73
|
+
)
|
|
74
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET)
|
|
75
|
+
rsu.race._(race_id).get()
|
|
76
|
+
|
|
77
|
+
sent = responses.calls[0].request
|
|
78
|
+
assert 'X-RSU-API-REG-SECRET' not in sent.headers
|
|
79
|
+
params = _qs(sent.url)
|
|
80
|
+
assert 'rsu_api_reg' not in params
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'''
|
|
2
|
+
opt-in live smoke tests against the real RunSignUp.com API
|
|
3
|
+
|
|
4
|
+
these hit the network and are skipped by default. to run them, set environment
|
|
5
|
+
variables and run:
|
|
6
|
+
|
|
7
|
+
pytest tests/test_runsignup_live.py -v
|
|
8
|
+
|
|
9
|
+
RSU_TEST_RACE_ID (a real, public race_id on runsignup.com) is required for the
|
|
10
|
+
public/unauthenticated tests. RSU_KEY / RSU_SECRET (and optionally
|
|
11
|
+
RSU_API_REG_TOKEN / RSU_API_REG_SECRET, per
|
|
12
|
+
https://info.runsignup.com/2026/07/17/new-api-registration-requirements/) enable
|
|
13
|
+
the authenticated tests. RSU_TEST_CLUB_ID (a real club_id the RSU_KEY/RSU_SECRET
|
|
14
|
+
account can administer) additionally enables the bad-credential rejection tests.
|
|
15
|
+
'''
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from running.runsignup import RunSignUp, accessError
|
|
22
|
+
from running.runsignup_fluent import RunSignupFluent
|
|
23
|
+
|
|
24
|
+
RACE_ID = os.environ.get('RSU_TEST_RACE_ID')
|
|
25
|
+
CLUB_ID = os.environ.get('RSU_TEST_CLUB_ID')
|
|
26
|
+
KEY = os.environ.get('RSU_KEY')
|
|
27
|
+
SECRET = os.environ.get('RSU_SECRET')
|
|
28
|
+
API_REG_TOKEN = os.environ.get('RSU_API_REG_TOKEN')
|
|
29
|
+
API_REG_SECRET = os.environ.get('RSU_API_REG_SECRET')
|
|
30
|
+
|
|
31
|
+
needs_race_id = pytest.mark.skipif(not RACE_ID, reason='set RSU_TEST_RACE_ID to run live tests')
|
|
32
|
+
needs_credentials = pytest.mark.skipif(
|
|
33
|
+
not (KEY and SECRET), reason='set RSU_KEY/RSU_SECRET to run authenticated live tests'
|
|
34
|
+
)
|
|
35
|
+
needs_api_reg = pytest.mark.skipif(
|
|
36
|
+
not (API_REG_TOKEN and API_REG_SECRET),
|
|
37
|
+
reason='set RSU_API_REG_TOKEN/RSU_API_REG_SECRET to run registered-caller live tests',
|
|
38
|
+
)
|
|
39
|
+
needs_club_id = pytest.mark.skipif(
|
|
40
|
+
not CLUB_ID, reason='set RSU_TEST_CLUB_ID to run bad-credential rejection live tests'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@needs_race_id
|
|
45
|
+
class TestRunSignUpLive:
|
|
46
|
+
def test_getrace_public(self):
|
|
47
|
+
with RunSignUp() as rsu:
|
|
48
|
+
race = rsu.getrace(int(RACE_ID))
|
|
49
|
+
assert str(race['race_id']) == str(RACE_ID)
|
|
50
|
+
|
|
51
|
+
@needs_api_reg
|
|
52
|
+
def test_getrace_with_api_reg(self):
|
|
53
|
+
with RunSignUp(api_reg_token=API_REG_TOKEN, api_reg_secret=API_REG_SECRET) as rsu:
|
|
54
|
+
race = rsu.getrace(int(RACE_ID))
|
|
55
|
+
assert str(race['race_id']) == str(RACE_ID)
|
|
56
|
+
|
|
57
|
+
@needs_credentials
|
|
58
|
+
def test_getrace_authenticated(self):
|
|
59
|
+
with RunSignUp(key=KEY, secret=SECRET, api_reg_token=API_REG_TOKEN, api_reg_secret=API_REG_SECRET) as rsu:
|
|
60
|
+
race = rsu.getrace(int(RACE_ID))
|
|
61
|
+
assert str(race['race_id']) == str(RACE_ID)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@needs_race_id
|
|
65
|
+
class TestRunSignupFluentLive:
|
|
66
|
+
def test_get_race_public(self):
|
|
67
|
+
rsu = RunSignupFluent()
|
|
68
|
+
resp = rsu.race._(int(RACE_ID)).get()
|
|
69
|
+
assert resp.status_code == 200
|
|
70
|
+
assert str(resp.json()['race']['race_id']) == str(RACE_ID)
|
|
71
|
+
|
|
72
|
+
@needs_credentials
|
|
73
|
+
def test_get_race_authenticated(self):
|
|
74
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET, api_reg_token=API_REG_TOKEN, api_reg_secret=API_REG_SECRET)
|
|
75
|
+
resp = rsu.race._(int(RACE_ID)).get()
|
|
76
|
+
assert resp.status_code == 200
|
|
77
|
+
assert str(resp.json()['race']['race_id']) == str(RACE_ID)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@needs_credentials
|
|
81
|
+
@needs_club_id
|
|
82
|
+
class TestBadPasswordLive:
|
|
83
|
+
'''confirms a wrong secret (right key, wrong password) is actually rejected by the live API'''
|
|
84
|
+
|
|
85
|
+
def test_runsignup_wrong_secret_rejected(self):
|
|
86
|
+
with RunSignUp(key=KEY, secret=SECRET + '-wrong-for-testing') as rsu:
|
|
87
|
+
with pytest.raises(accessError):
|
|
88
|
+
rsu.members(int(CLUB_ID))
|
|
89
|
+
|
|
90
|
+
def test_runsignup_fluent_wrong_secret_rejected(self):
|
|
91
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET + '-wrong-for-testing')
|
|
92
|
+
resp = rsu.club._(int(CLUB_ID)).members.get()
|
|
93
|
+
assert resp.status_code != 200 or 'error' in resp.json()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@needs_credentials
|
|
97
|
+
@needs_club_id
|
|
98
|
+
class TestBadApiRegTokenLive:
|
|
99
|
+
'''
|
|
100
|
+
confirms that when an api_reg_token/api_reg_secret IS supplied, RunSignUp actually validates it
|
|
101
|
+
and rejects a wrong one -- distinct from omitting it entirely, which RunSignUp currently accepts
|
|
102
|
+
since registered-caller credentials aren't required until 2027-01-01 per
|
|
103
|
+
https://info.runsignup.com/2026/07/17/new-api-registration-requirements/. This test only tells you
|
|
104
|
+
whether a *provided* bad token/secret is checked, not whether one is required yet.
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
def test_runsignup_wrong_api_reg_rejected(self):
|
|
108
|
+
with RunSignUp(key=KEY, secret=SECRET,
|
|
109
|
+
api_reg_token='bad-token-for-testing', api_reg_secret='bad-secret-for-testing') as rsu:
|
|
110
|
+
with pytest.raises(accessError):
|
|
111
|
+
rsu.members(int(CLUB_ID))
|
|
112
|
+
|
|
113
|
+
def test_runsignup_fluent_wrong_api_reg_rejected(self):
|
|
114
|
+
rsu = RunSignupFluent(key=KEY, secret=SECRET,
|
|
115
|
+
api_reg_token='bad-token-for-testing', api_reg_secret='bad-secret-for-testing')
|
|
116
|
+
resp = rsu.club._(int(CLUB_ID)).members.get()
|
|
117
|
+
assert resp.status_code != 200 or 'error' in resp.json()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|