├── .gitignore ├── Pipfile ├── README.md ├── UNLICENSE.md ├── intervals.py ├── drf.py └── thttp.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | Pipfile.lock 3 | __pycache__ 4 | 5 | # private files for messing with multiple workouts 6 | workouts.py 7 | workouts.txt 8 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | 8 | [dev-packages] 9 | 10 | [requires] 11 | python_version = "3.10" 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `daniels-running-formula-to-intervals` takes a runnings workout in the form of "6 E + 5 x (3 min I w/2 min jg recoveries) + 6 x (1 min R w/2 min jg) + 2 E" and sends it to Intervals.icu as a workout. 2 | 3 | ## Usage 4 | 5 | There are no dependencies, so usage is as simple as: 6 | 7 | ``` 8 | python3 drf.py 9 | ``` 10 | 11 | The optional `--metric` flag allows you to specify the formula using metric distances. 12 | 13 | The CLI will prompt you for your Intervals Athlete ID and Intervals API Key, you can add these as environment variables (`INTERVALS_ALTHETE_ID` and `INTERVALS_API_KEY`) to load them from their instead. 14 | 15 | 16 | ## Notes 17 | 18 | The parsing of the DRF string is _hacky_, but it covers the full Q2 plan from the book with the exception of strings like "steady E run of 90-120 min" which needs to be converted to "120 min E". 19 | 20 | Also not working: "6 E + 5 x (3 min I w/2 min E) + 4 E". This is because of the "I" and "E" inside the brackets. Will fix on cleanup. Can be converted to "6 E + 5 x (3 min I w/2 min jog recoveries) + 4 E" 21 | -------------------------------------------------------------------------------- /UNLICENSE.md: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /intervals.py: -------------------------------------------------------------------------------- 1 | from .thttp import request 2 | 3 | 4 | def upload_to_intervals( 5 | workout_str, workout_name, athlete_id, api_key, folder_name="Daniels 2Q" 6 | ): 7 | url = f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders" 8 | 9 | # check to see if the folder_name folder already exists 10 | response = request( 11 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders", 12 | basic_auth=("API_KEY", api_key), 13 | ) 14 | if response.status != 200: 15 | print(f"Error requesting folders: {response.json}") 16 | return 17 | 18 | folders = [ 19 | x for x in response.json if x["name"] == folder_name and x["type"] == "FOLDER" 20 | ] 21 | 22 | # create a folder if it doesn't exist 23 | if not folders: 24 | response = request( 25 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders", 26 | json={"name": folder_name, "type": "FOLDER"}, 27 | method="post", 28 | basic_auth=("API_KEY", api_key), 29 | ) 30 | 31 | if response.status > 299: 32 | print(f"Error creating folder: {response.json}") 33 | return 34 | 35 | folder = response.json 36 | else: 37 | folder = folders[0] 38 | 39 | # upload our workout to that folder 40 | response = request( 41 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/workouts", 42 | method="post", 43 | json=[ 44 | { 45 | "description": workout_str, 46 | "folder_id": folder["id"], 47 | "indoor": False, 48 | "name": workout_name, 49 | "type": "Run", 50 | } 51 | ], 52 | basic_auth=("API_KEY", api_key), 53 | ) 54 | 55 | if response.status > 299: 56 | print(f"Error creating workout: {response.json}") 57 | return 58 | else: 59 | print("Successfully created workout") 60 | 61 | return response.json 62 | -------------------------------------------------------------------------------- /drf.py: -------------------------------------------------------------------------------- 1 | from .intervals import upload_to_intervals 2 | import os 3 | import sys 4 | 5 | 6 | PACES = { 7 | "ST": ("105", "130"), 8 | "L": ("59", "74"), 9 | "E": ("59", "74"), 10 | "M": ("75", "84"), 11 | "T": ("83", "88"), 12 | "I": ("95", "100"), 13 | "R": ("105", "110"), 14 | } 15 | 16 | 17 | def convert(workout, metric=False): 18 | # cleanup some funk 19 | workout = workout.replace("×", "x") 20 | 21 | converted = [] 22 | steps = [s.strip() for s in workout.split("+")] 23 | 24 | for step in steps: 25 | repeats = 1 26 | 27 | if "x" in step: 28 | repeats, step = step.split("x", 1) 29 | repeats = int(repeats) 30 | step = step.replace("(", "").replace(")", "") 31 | 32 | if "ST" in step: 33 | repeats = int(step.split("ST")[0].strip()) 34 | 35 | for k in PACES: 36 | if k in step: 37 | strides = "ST" in step 38 | recovery = None 39 | 40 | if "w/" in step: 41 | step, recovery = step.split("w/", 1) 42 | elif strides: 43 | recovery = "1 min rest" 44 | 45 | step = step.replace(k, "") 46 | step = step.strip() 47 | 48 | for _ in range(repeats): 49 | if "min" in step: 50 | mins = int(step.replace("min", "").strip()) 51 | converted.append( 52 | f"- {mins}m00 {PACES[k][0]}-{PACES[k][1]}% Pace" 53 | ) 54 | elif strides: 55 | converted.append(f"- 0m20 {PACES[k][0]}-{PACES[k][1]}% Pace") 56 | else: 57 | dist = int(step) 58 | 59 | if dist > 50: 60 | # probably meters 61 | km = dist / 1000 62 | elif not metric: 63 | km = dist * 1.6 64 | else: 65 | km = dist 66 | 67 | converted.append( 68 | f"- {km:.2f}km {PACES[k][0]}-{PACES[k][1]}% Pace" 69 | ) 70 | 71 | if recovery: 72 | if "min" in recovery: 73 | minutes, recovery_type = recovery.split("min") 74 | minutes = int(minutes) 75 | 76 | if recovery_type.strip() in [ 77 | "rest", 78 | "rests", 79 | "recovery between", 80 | ]: 81 | converted.append(f"- {minutes}m00 Rest") 82 | 83 | if recovery_type.strip() in ["jg recoveries", "jg"]: 84 | converted.append(f"- {minutes}m00 50-70% Pace") 85 | else: 86 | dist, recovery_type = recovery.strip().split(" ", 1) 87 | 88 | dist = int(dist) 89 | 90 | if dist > 30: 91 | # probably meters 92 | dist = dist / 1000 93 | elif not metric: 94 | dist = dist * 1.6 95 | 96 | converted.append(f"- {dist:.2f}km 50-70% Pace") 97 | 98 | return "\n".join(converted) 99 | 100 | 101 | if __name__ == "__main__": 102 | athlete_id = os.environ.get("INTERVALS_ATHLETE_ID") or input( 103 | "Intervals Athlete ID: " 104 | ) 105 | api_key = os.environ.get("INTERVALS_API_KEY") or input("Intervals API Key: ") 106 | 107 | workout_name = input("Workout name: ") 108 | workout_str = input("Workout (Daniels' Formula): ") 109 | 110 | if not all([athlete_id, api_key, workout_name, workout_str]): 111 | sys.exit("All fields are required.") 112 | 113 | intervals_str = convert(workout_str, metric="--metric" in sys.argv) 114 | print(intervals_str) 115 | 116 | upload = input("Upload [yN]?") 117 | if upload == "y": 118 | upload_to_intervals( 119 | intervals_str, workout_name, athlete_id, api_key, "Daniels 2Q" 120 | ) 121 | -------------------------------------------------------------------------------- /thttp.py: -------------------------------------------------------------------------------- 1 | """ 2 | UNLICENSED 3 | This is free and unencumbered software released into the public domain. 4 | 5 | https://github.com/sesh/thttp 6 | """ 7 | 8 | import gzip 9 | import ssl 10 | import json as json_lib 11 | 12 | from base64 import b64encode 13 | from collections import namedtuple 14 | 15 | from http.cookiejar import CookieJar 16 | from urllib.error import HTTPError, URLError 17 | from urllib.parse import urlencode 18 | from urllib.request import ( 19 | Request, 20 | build_opener, 21 | HTTPRedirectHandler, 22 | HTTPSHandler, 23 | HTTPCookieProcessor, 24 | ) 25 | 26 | 27 | Response = namedtuple("Response", "request content json status url headers cookiejar") 28 | 29 | 30 | class NoRedirect(HTTPRedirectHandler): 31 | def redirect_request(self, req, fp, code, msg, headers, newurl): 32 | return None 33 | 34 | 35 | def request( 36 | url, 37 | params={}, 38 | json=None, 39 | data=None, 40 | headers={}, 41 | method="GET", 42 | verify=True, 43 | redirect=True, 44 | cookiejar=None, 45 | basic_auth=None, 46 | timeout=None, 47 | ): 48 | """ 49 | Returns a (named)tuple with the following properties: 50 | - request 51 | - content 52 | - json (dict; or None) 53 | - headers (dict; all lowercase keys) 54 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 55 | - status 56 | - url (final url, after any redirects) 57 | - cookiejar 58 | """ 59 | method = method.upper() 60 | headers = {k.lower(): v for k, v in headers.items()} # lowercase headers 61 | 62 | if params: 63 | url += "?" + urlencode(params) # build URL from query parameters 64 | 65 | if json and data: 66 | raise Exception("Cannot provide both json and data parameters") 67 | 68 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 69 | raise Exception( 70 | "Request method must POST, PATCH or PUT if json or data is provided" 71 | ) 72 | 73 | if not timeout: 74 | timeout = 60 75 | 76 | if json: # if we have json, dump it to a string and put it in our data variable 77 | headers["content-type"] = "application/json" 78 | data = json_lib.dumps(json).encode("utf-8") 79 | elif data: 80 | data = urlencode(data).encode() 81 | 82 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 83 | username, password = basic_auth 84 | headers[ 85 | "authorization" 86 | ] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 87 | 88 | if not cookiejar: 89 | cookiejar = CookieJar() 90 | 91 | ctx = ssl.create_default_context() 92 | if not verify: # ignore ssl errors 93 | ctx.check_hostname = False 94 | ctx.verify_mode = ssl.CERT_NONE 95 | 96 | handlers = [] 97 | handlers.append(HTTPSHandler(context=ctx)) 98 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 99 | 100 | if not redirect: 101 | no_redirect = NoRedirect() 102 | handlers.append(no_redirect) 103 | 104 | opener = build_opener(*handlers) 105 | req = Request(url, data=data, headers=headers, method=method) 106 | 107 | try: 108 | with opener.open(req, timeout=timeout) as resp: 109 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 110 | headers = {k.lower(): v for k, v in list(resp.info().items())} 111 | 112 | if "gzip" in headers.get("content-encoding", ""): 113 | content = gzip.decompress(content) 114 | 115 | json = ( 116 | json_lib.loads(content) 117 | if "application/json" in headers.get("content-type", "").lower() 118 | and content 119 | else None 120 | ) 121 | except HTTPError as e: 122 | status, content, resp_url = (e.code, e.read(), e.geturl()) 123 | headers = {k.lower(): v for k, v in list(e.headers.items())} 124 | 125 | if "gzip" in headers.get("content-encoding", ""): 126 | content = gzip.decompress(content) 127 | 128 | json = ( 129 | json_lib.loads(content) 130 | if "application/json" in headers.get("content-type", "").lower() and content 131 | else None 132 | ) 133 | 134 | return Response(req, content, json, status, resp_url, headers, cookiejar) 135 | 136 | 137 | import unittest 138 | 139 | 140 | class RequestTestCase(unittest.TestCase): 141 | def test_cannot_provide_json_and_data(self): 142 | with self.assertRaises(Exception): 143 | request( 144 | "https://httpbingo.org/post", 145 | json={"name": "Brenton"}, 146 | data="This is some form data", 147 | ) 148 | 149 | def test_should_fail_if_json_or_data_and_not_p_method(self): 150 | with self.assertRaises(Exception): 151 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 152 | 153 | with self.assertRaises(Exception): 154 | request( 155 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD" 156 | ) 157 | 158 | def test_should_set_content_type_for_json_request(self): 159 | response = request( 160 | "https://httpbingo.org/post", json={"name": "Brenton"}, method="POST" 161 | ) 162 | self.assertEqual(response.request.headers["Content-type"], "application/json") 163 | 164 | def test_should_work(self): 165 | response = request("https://httpbingo.org/get") 166 | self.assertEqual(response.status, 200) 167 | 168 | def test_should_create_url_from_params(self): 169 | response = request( 170 | "https://httpbingo.org/get", 171 | params={"name": "brenton", "library": "tiny-request"}, 172 | ) 173 | self.assertEqual( 174 | response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request" 175 | ) 176 | 177 | def test_should_return_headers(self): 178 | response = request( 179 | "https://httpbingo.org/response-headers", params={"Test-Header": "value"} 180 | ) 181 | self.assertEqual(response.headers["test-header"], "value") 182 | 183 | def test_should_populate_json(self): 184 | response = request("https://httpbingo.org/json") 185 | self.assertTrue("slideshow" in response.json) 186 | 187 | def test_should_return_response_for_404(self): 188 | response = request("https://httpbingo.org/404") 189 | self.assertEqual(response.status, 404) 190 | self.assertTrue("text/plain" in response.headers["content-type"]) 191 | 192 | def test_should_fail_with_bad_ssl(self): 193 | with self.assertRaises(URLError): 194 | response = request("https://expired.badssl.com/") 195 | 196 | def test_should_load_bad_ssl_with_verify_false(self): 197 | response = request("https://expired.badssl.com/", verify=False) 198 | self.assertEqual(response.status, 200) 199 | 200 | def test_should_form_encode_non_json_post_requests(self): 201 | response = request( 202 | "https://httpbingo.org/post", data={"name": "test-user"}, method="POST" 203 | ) 204 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 205 | 206 | def test_should_follow_redirect(self): 207 | response = request( 208 | "https://httpbingo.org/redirect-to", 209 | params={"url": "https://duckduckgo.com/"}, 210 | ) 211 | self.assertEqual(response.url, "https://duckduckgo.com/") 212 | self.assertEqual(response.status, 200) 213 | 214 | def test_should_not_follow_redirect_if_redirect_false(self): 215 | response = request( 216 | "https://httpbingo.org/redirect-to", 217 | params={"url": "https://duckduckgo.com/"}, 218 | redirect=False, 219 | ) 220 | self.assertEqual(response.status, 302) 221 | 222 | def test_cookies(self): 223 | response = request( 224 | "https://httpbingo.org/cookies/set", 225 | params={"cookie": "test"}, 226 | redirect=False, 227 | ) 228 | response = request( 229 | "https://httpbingo.org/cookies", cookiejar=response.cookiejar 230 | ) 231 | self.assertEqual(response.json["cookie"], "test") 232 | 233 | def test_basic_auth(self): 234 | response = request( 235 | "http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd") 236 | ) 237 | self.assertEqual(response.json["authorized"], True) 238 | 239 | def test_should_handle_gzip(self): 240 | response = request( 241 | "http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"} 242 | ) 243 | self.assertEqual(response.json["gzipped"], True) 244 | 245 | def test_should_handle_gzip_error(self): 246 | response = request( 247 | "http://httpbingo.org/status/418", headers={"Accept-Encoding": "gzip"} 248 | ) 249 | self.assertEqual(response.content, b"I'm a teapot!") 250 | 251 | def test_should_timeout(self): 252 | import socket 253 | 254 | with self.assertRaises((TimeoutError, socket.timeout)): 255 | response = request("http://httpbingo.org/delay/3", timeout=1) 256 | 257 | def test_should_handle_head_requests(self): 258 | response = request("http://httpbingo.org/head", method="HEAD") 259 | self.assertTrue(response.content == b"") 260 | --------------------------------------------------------------------------------