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.
Files changed (46) hide show
  1. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/PKG-INFO +1 -1
  2. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runsignup.py +92 -146
  3. runtilities-3.0.0/running/runsignup_fluent.py +36 -0
  4. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/version.py +1 -1
  5. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/PKG-INFO +1 -1
  6. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/SOURCES.txt +5 -1
  7. runtilities-3.0.0/tests/test_runsignup.py +154 -0
  8. runtilities-3.0.0/tests/test_runsignup_fluent.py +80 -0
  9. runtilities-3.0.0/tests/test_runsignup_live.py +117 -0
  10. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/README.md +0 -0
  11. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/__init__.py +0 -0
  12. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/analyzeagegrade.py +0 -0
  13. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/athlinks.py +0 -0
  14. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/athlinksresults.py +0 -0
  15. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/comparegpx.py +0 -0
  16. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/competitor.py +0 -0
  17. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/competitor2csv.py +0 -0
  18. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/getweather.py +0 -0
  19. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/gpx2kml.py +0 -0
  20. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parseralogxml.py +0 -0
  21. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parseresults.py +0 -0
  22. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parserun-bk3.py +0 -0
  23. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/parsetcx.py +0 -0
  24. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/ra2membersfile.py +0 -0
  25. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/racewx-render.py +0 -0
  26. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/racewx.py +0 -0
  27. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/renderclubagstats.py +0 -0
  28. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningahead.py +0 -0
  29. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningaheadmembers.py +0 -0
  30. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningaheadparticipants.py +0 -0
  31. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/runningaheadresults.py +0 -0
  32. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/strava.py +0 -0
  33. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/textnormalize.py +0 -0
  34. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/ultrasignup.py +0 -0
  35. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/ultrasignupresults.py +0 -0
  36. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmldict.py +0 -0
  37. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmldict1.py +0 -0
  38. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmldict2.py +0 -0
  39. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/running/xmlreader.py +0 -0
  40. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/dependency_links.txt +0 -0
  41. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/entry_points.txt +0 -0
  42. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/not-zip-safe +0 -0
  43. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/requires.txt +0 -0
  44. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/runtilities.egg-info/top_level.txt +0 -0
  45. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/setup.cfg +0 -0
  46. {runtilities-2.3.0.dev2 → runtilities-3.0.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtilities
3
- Version: 2.3.0.dev2
3
+ Version: 3.0.0
4
4
  Summary: running related scripts
5
5
  Home-page: http://github.com/louking/running
6
6
  Author: Lou King
@@ -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 RunSignUp():
50
+ class RunSignupBase():
78
51
  '''
79
- access methods for RunSignUp.com
52
+ common session / authentication / low-level request handling for RunSignUp.com REST API clients
80
53
 
81
- either key and secret OR email and password should be supplied
82
- key and secret take precedence
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 email: email for use by Login API (deprecated)
87
- :param password: password for use by Login API (deprecated)
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, email=None, password=None, debug=False):
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 = False
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 (email and not password) or (password and not email):
117
- raise parameterError('email and password must be supplied together')
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.email = email
122
- self.password = password
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
- # key / secret supplied - this take precedence
147
- if self.credentials_type == 'key':
148
- self.client_credentials = {'api_key' : self.key,
149
- 'api_secret' : self.secret}
150
-
151
- # email / password supplied
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
- # TODO: should we also log out?
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, email=None, password=None, debug=False):
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, email=email, password=password, debug=debug)
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.3.0.dev2'
2
+ __version__ = '3.0.0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtilities
3
- Version: 2.3.0.dev2
3
+ Version: 3.0.0
4
4
  Summary: running related scripts
5
5
  Home-page: http://github.com/louking/running
6
6
  Author: Lou King
@@ -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