├── images └── workout.png ├── pyproject.toml ├── fitlek ├── utils.py ├── getfit.py ├── fartlek.py ├── garmin.py ├── intervals.py ├── workout.py └── thttp.py ├── README.md ├── .github └── workflows │ └── check.yml ├── cli.py └── .gitignore /images/workout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/fitlek/main/images/workout.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 120 3 | 4 | [tool.black] 5 | line-length = 120 6 | 7 | [tool.isort] 8 | profile = "black" 9 | 10 | [tool.bandit] 11 | skips = ["B311"] -------------------------------------------------------------------------------- /fitlek/utils.py: -------------------------------------------------------------------------------- 1 | def mmss_to_seconds(s): 2 | parts = s.split(":") 3 | 4 | if len(parts) == 2: 5 | return int(parts[0]) * 60 + int(parts[1]) 6 | else: 7 | raise Exception("Invalid duration provided, must use mm:ss format") 8 | 9 | 10 | def seconds_to_mmss(seconds): 11 | mins = int(seconds / 60) 12 | seconds = seconds - mins * 60 13 | return f"{mins:02}:{seconds:02}" 14 | 15 | 16 | def pace_to_ms(pace): 17 | seconds = mmss_to_seconds(pace) 18 | km_h = 60 / (seconds / 60) 19 | return km_h * 0.27778 20 | -------------------------------------------------------------------------------- /fitlek/getfit.py: -------------------------------------------------------------------------------- 1 | from .thttp import request 2 | 3 | GETFIT_URL = "https://getfitfile.azurewebsites.net/api/getfitfromjson" 4 | 5 | 6 | def getfit_download(workout_name, workout, save=True): 7 | j = { 8 | "name": workout_name, 9 | "steps": [ 10 | { 11 | "intensity": step.step_type, 12 | "duration": int(step.parsed_end_condition_value()), 13 | "targetSpeedLow": step.target.from_value if step.target.from_value else None, 14 | "targetSpeedHigh": step.target.to_value if step.target.to_value else None, 15 | } 16 | for step in workout.workout_steps 17 | ], 18 | } 19 | 20 | response = request(GETFIT_URL, json=j, method="POST") 21 | if response.status == 200: 22 | if save: 23 | with open("fitlek.fit", "wb") as f: 24 | f.write(response.content) 25 | else: 26 | return response.content 27 | else: 28 | print(response.content) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `fitlek` adds a randomised Fartlek workout to Garmin Connect for you. 2 | 3 | It's called `FITlek` because the original intention was to create a `.fit` file that you could upload directly to your watch. That might still happen but going directly to Garmin is working for me right now.. 4 | 5 | --- 6 | 7 | If you just want to give this a go, check out [Run Randomly](https://runrandomly.com)! That's a hosted version of this script with support for downloading FIT files and syncing to [Intervals](https://intervals.icu). 8 | 9 | --- 10 | 11 | 12 | ### Usage 13 | 14 | The easiest way to create a new workout for yourself if to clone the repo and run the script. There are no requirements other than a modern version of Python 3 (tested with 3.8). 15 | 16 | ``` 17 | > git clone https://github.com/sesh/fitlek.git 18 | > cd fitlek 19 | > python3 fitlek.py --duration=30:00 --target-pace=04:00 --username="your-garmin-connect-username" --password="your-garmin-connect-password" 20 | ``` 21 | 22 | Running the above should result in a new workout that looks something like this: 23 | 24 | ![workout](images/workout.png) 25 | 26 | 27 | ### Acknowledgements 28 | 29 | - The login to Garmin Connect is heavily copied from [petergardfjall/garminexport](https://github.com/petergardfjall/garminexport). There's lots of great work in that project, definitely worth checking out if you're interested in working with Garmin Connect. 30 | - Some details about the Workouts format were taken from [mgifos/quick-plan](https://github.com/mgifos/quick-plan/). If I'm inspired to work on this project more I expect it to start looking more like `quick-plan`. 31 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Python Checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | black: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.11" 21 | 22 | - name: Install black 23 | run: | 24 | python -m pip install black 25 | 26 | - name: Run black 27 | run: | 28 | black --check . 29 | 30 | isort: 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | 36 | - name: Set up Python 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: "3.11" 40 | 41 | - name: Install isort 42 | run: | 43 | python -m pip install isort 44 | 45 | - name: Run isort 46 | run: | 47 | isort --check . 48 | 49 | ruff: 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - uses: actions/checkout@v3 54 | 55 | - name: Set up Python 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: "3.11" 59 | 60 | - name: Install ruff 61 | run: | 62 | python -m pip install ruff 63 | 64 | - name: Run ruff 65 | run: | 66 | ruff --format=github . 67 | 68 | bandit: 69 | runs-on: ubuntu-latest 70 | 71 | steps: 72 | - uses: actions/checkout@v3 73 | 74 | - name: Set up Python 75 | uses: actions/setup-python@v4 76 | with: 77 | python-version: 3 78 | 79 | - name: Install bandit 80 | run: | 81 | python -m pip install bandit[toml] 82 | 83 | - name: Run bandit scan 84 | run: | 85 | bandit -c pyproject.toml -r . 86 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | import json 3 | import sys 4 | 5 | from fitlek.fartlek import create_fartlek_workout 6 | from fitlek.garmin import GarminClient 7 | 8 | try: 9 | from fitlek.getfit import getfit_download 10 | 11 | ENABLE_GETFIT = True 12 | except ImportError: 13 | print("Getfit disabled") 14 | ENABLE_GETFIT = False 15 | 16 | 17 | def parse_args(args): 18 | result = { 19 | a.split("=")[0]: int(a.split("=")[1]) 20 | if "=" in a and a.split("=")[1].isnumeric() 21 | else a.split("=")[1] 22 | if "=" in a 23 | else True 24 | for a in args 25 | if "--" in a 26 | } 27 | result["[]"] = [a for a in args if not a.startswith("--")] 28 | return result 29 | 30 | 31 | def get_or_throw(d, key, error): 32 | try: 33 | return d[key] 34 | except: # noqa: E722 35 | raise Exception(error) 36 | 37 | 38 | if __name__ == "__main__": 39 | args = parse_args(sys.argv) 40 | 41 | duration = get_or_throw(args, "--duration", "The --duration value is required (format: MM:SS)") 42 | target_pace = get_or_throw( 43 | args, 44 | "--target-pace", 45 | "The --target-pace value is required (format: MM:SS - mins/km)", 46 | ) 47 | 48 | workout = create_fartlek_workout(duration, target_pace) 49 | 50 | if "--dry-run" in args: 51 | print(json.dumps(workout.garminconnect_json(), indent=2)) 52 | elif ENABLE_GETFIT and "--fit" in args: 53 | getfit_download(workout) 54 | else: 55 | username = get_or_throw(args, "--username", "The Garmin Connect --username value is required") 56 | password = get_or_throw(args, "--password", "The Garmin Connect --password value is required") 57 | 58 | client = GarminClient(username, password) 59 | client.connect() 60 | client.add_workout(workout) 61 | 62 | print("Added workout. Check https://connect.garmin.com/modern/workouts and get ready to run!") 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/7eef17f37c63ce3cbddbdd154ff836f370d0ad70/Python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | # Test files 128 | fitlek.fit -------------------------------------------------------------------------------- /fitlek/fartlek.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from .utils import mmss_to_seconds, pace_to_ms, seconds_to_mmss 4 | from .workout import Target, Workout, WorkoutStep 5 | 6 | 7 | def fartlek(target_time): 8 | target_seconds = mmss_to_seconds(target_time) 9 | 10 | if target_seconds >= 40 * 60: 11 | # runs greater than 40 minutes == 10 minute warmup / cooldown 12 | warmup = 60 * 10 13 | cooldown = 60 * 10 14 | elif target_seconds >= 25 * 60: 15 | # runs greater than 25 mins == 8 mins warmup + 4 mins cooldown 16 | warmup = 60 * 8 17 | cooldown = 60 * 4 18 | else: 19 | # all other runs get a 5 minute warmup and 2 minute cooldown 20 | warmup = 60 * 5 21 | cooldown = 60 * 2 22 | 23 | workout = [warmup, cooldown] 24 | 25 | while sum(workout) < target_seconds: 26 | # add an interval, recovery pair 27 | interval = 15 * random.randint(2, 8) 28 | recovery = interval + 15 * random.randint(2, 4) 29 | 30 | workout.insert(-1, interval) 31 | workout.insert(-1, recovery) 32 | 33 | if sum(workout) > target_seconds: 34 | # remove the last interval and increase something by the amount remaining 35 | workout.pop(-2) + workout.pop(-2) 36 | remaining = target_seconds - sum(workout) 37 | i = random.randint(1, len(workout) - 1) 38 | workout[i] += remaining 39 | 40 | return workout 41 | 42 | 43 | def create_fartlek_workout(duration, target_pace, name=None): 44 | workout_steps = fartlek(duration) 45 | target_min = round(pace_to_ms(target_pace) * 1.10, 2) 46 | target_max = round(pace_to_ms(target_pace) * 0.9, 2) 47 | 48 | if not name: 49 | name = f"Fitlek ({duration})" 50 | w = Workout("running", name) 51 | w.add_step( 52 | WorkoutStep( 53 | 1, 54 | "warmup", 55 | end_condition="time", 56 | end_condition_value=seconds_to_mmss(workout_steps.pop(0)), 57 | ) 58 | ) 59 | 60 | for i, step in enumerate(workout_steps[:-1]): 61 | step_type = "interval" if i % 2 == 0 else "recovery" 62 | target = Target("pace.zone", target_min, target_max) if step_type == "interval" else Target() 63 | w.add_step( 64 | WorkoutStep( 65 | i + 2, 66 | step_type, 67 | end_condition="time", 68 | end_condition_value=seconds_to_mmss(step), 69 | target=target, 70 | ) 71 | ) 72 | 73 | w.add_step( 74 | WorkoutStep( 75 | len(w.workout_steps) + 1, 76 | "cooldown", 77 | end_condition="time", 78 | end_condition_value=seconds_to_mmss(workout_steps[-1]), 79 | ) 80 | ) 81 | 82 | return w 83 | -------------------------------------------------------------------------------- /fitlek/garmin.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .thttp import request 4 | 5 | SSO_LOGIN_URL = "https://sso.garmin.com/sso/signin" 6 | 7 | 8 | class GarminClient: 9 | """ 10 | This is a modified version of: 11 | https://raw.githubusercontent.com/petergardfjall/garminexport/master/garminexport/garminclient.py 12 | 13 | The Garmin Export project was originally released under the Apache License 2.0. 14 | 15 | Lots of details about the Workouts grokked from: 16 | https://github.com/mgif/quick-plan 17 | """ 18 | 19 | def __init__(self, username, password): 20 | self.username = username 21 | self.password = password 22 | self.cookiejar = None 23 | 24 | def connect(self): 25 | self._authenticate() 26 | 27 | def _authenticate(self): 28 | form_data = { 29 | "username": self.username, 30 | "password": self.password, 31 | "embed": "false", 32 | } 33 | 34 | request_params = {"service": "https://connect.garmin.com/modern"} 35 | headers = { 36 | "origin": "https://sso.garmin.com", 37 | "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:82.0) Gecko/20100101 Firefox/82.0", 38 | } 39 | 40 | auth_response = request( 41 | SSO_LOGIN_URL, 42 | headers=headers, 43 | params=request_params, 44 | data=form_data, 45 | method="POST", 46 | ) 47 | 48 | self.cookiejar = auth_response.cookiejar 49 | 50 | if auth_response.status != 200: 51 | raise ValueError("authentication failure: did you enter valid credentials?") 52 | 53 | auth_ticket_url = self._extract_auth_ticket_url(auth_response.content.decode()) 54 | response = request(auth_ticket_url, cookiejar=self.cookiejar, headers=headers) 55 | 56 | if response.status != 200: 57 | raise RuntimeError( 58 | "auth failure: failed to claim auth ticket: {}: {}\n{}".format( 59 | auth_ticket_url, response.status, response.content 60 | ) 61 | ) 62 | 63 | @staticmethod 64 | def _extract_auth_ticket_url(auth_response): 65 | match = re.search(r'response_url\s*=\s*"(https:[^"]+)"', auth_response) 66 | if not match: 67 | raise RuntimeError( 68 | "auth failure: unable to extract auth ticket URL. did you provide a correct username/password?" 69 | ) 70 | auth_ticket_url = match.group(1).replace("\\", "") 71 | return auth_ticket_url 72 | 73 | def add_workout(self, workout): 74 | response = request( 75 | "https://connect.garmin.com/modern/proxy/workout-service/workout", 76 | method="POST", 77 | json=workout.garminconnect_json(), 78 | headers={ 79 | "Referer": "https://connect.garmin.com/modern/workout/create/running", 80 | "NK": "NT", 81 | "X-app-ver": "4.38.2.0", 82 | "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:82.0) Gecko/20100101 Firefox/82.0", 83 | }, 84 | cookiejar=self.cookiejar, 85 | ) 86 | 87 | if response.status > 299: 88 | print(response) 89 | return response.json 90 | -------------------------------------------------------------------------------- /fitlek/intervals.py: -------------------------------------------------------------------------------- 1 | from .thttp import request 2 | 3 | 4 | def upload_str_to_intervals(workout_str, workout_name, athlete_id, api_key, folder_name="Run Randomly"): 5 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders" 6 | 7 | # check to see if the Run Randomly folder already exists 8 | response = request( 9 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders", 10 | headers={ 11 | "Authorization": f"Bearer {api_key}", 12 | }, 13 | ) 14 | 15 | folders = [x for x in response.json if x["name"] == folder_name and x["type"] == "FOLDER"] 16 | 17 | # create a folder if it doesn't exist 18 | if not folders: 19 | response = request( 20 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders", 21 | json={"name": folder_name, "type": "FOLDER"}, 22 | method="post", 23 | headers={ 24 | "Authorization": f"Bearer {api_key}", 25 | }, 26 | ) 27 | print(response.json) 28 | folder = response.json 29 | else: 30 | folder = folders[0] 31 | 32 | # upload our workout to that folder 33 | response = request( 34 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/workouts", 35 | method="post", 36 | json=[ 37 | { 38 | "description": workout_str, 39 | "folder_id": folder["id"], 40 | "indoor": False, 41 | "name": workout_name, 42 | "type": "Run", 43 | } 44 | ], 45 | headers={ 46 | "Authorization": f"Bearer {api_key}", 47 | }, 48 | ) 49 | 50 | return response.json 51 | 52 | 53 | def upload_to_intervals(workout, athlete_id, api_key, folder_name="Run Randomly"): 54 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders" 55 | 56 | # check to see if the Run Randomly folder already exists 57 | response = request( 58 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders", 59 | headers={ 60 | "Authorization": f"Bearer {api_key}", 61 | }, 62 | ) 63 | 64 | folders = [x for x in response.json if x["name"] == folder_name and x["type"] == "FOLDER"] 65 | 66 | # create a folder if it doesn't exist 67 | if not folders: 68 | response = request( 69 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/folders", 70 | json={"name": folder_name, "type": "FOLDER"}, 71 | method="post", 72 | headers={ 73 | "Authorization": f"Bearer {api_key}", 74 | }, 75 | ) 76 | print(response.json) 77 | folder = response.json 78 | else: 79 | folder = folders[0] 80 | 81 | # create the workout string 82 | workout_str = "" 83 | paces = { 84 | "warmup": "60-80%", 85 | "interval": "90-120%", 86 | "recovery": "60-90%", 87 | "cooldown": "60-80%", 88 | } 89 | 90 | for step in workout.workout_steps: 91 | workout_str += step.step_type + "\n" 92 | workout_str += f"- {step.end_condition_value.replace(':', 'm')}s {paces[step.step_type]} Pace\n\n" 93 | 94 | # upload our workout to that folder 95 | response = request( 96 | f"https://intervals.icu/api/v1/athlete/{athlete_id}/workouts", 97 | method="post", 98 | json=[ 99 | { 100 | "description": workout_str, 101 | "folder_id": folder["id"], 102 | "indoor": False, 103 | "name": workout.workout_name, 104 | "type": "Run", 105 | } 106 | ], 107 | headers={ 108 | "Authorization": f"Bearer {api_key}", 109 | }, 110 | ) 111 | 112 | return response.json 113 | -------------------------------------------------------------------------------- /fitlek/workout.py: -------------------------------------------------------------------------------- 1 | SPORT_TYPES = { 2 | "running": 1, 3 | } 4 | 5 | STEP_TYPES = {"warmup": 1, "cooldown": 2, "interval": 3, "recovery": 4} 6 | 7 | END_CONDITIONS = { 8 | "lap.button": 1, 9 | "time": 2, 10 | "distance": 3, 11 | } 12 | 13 | TARGET_TYPES = { 14 | "no.target": 1, 15 | "power.zone": 2, 16 | "cadence.zone": 3, 17 | "heart.rate.zone": 4, 18 | "speed.zone": 5, 19 | "pace.zone": 6, # meters per second 20 | } 21 | 22 | 23 | class Workout: 24 | def __init__(self, sport_type, name): 25 | self.sport_type = sport_type 26 | self.workout_name = name 27 | self.workout_steps = [] 28 | 29 | def add_step(self, step): 30 | self.workout_steps.append(step) 31 | 32 | def garminconnect_json(self): 33 | return { 34 | "sportType": { 35 | "sportTypeId": SPORT_TYPES[self.sport_type], 36 | "sportTypeKey": self.sport_type, 37 | }, 38 | "workoutName": self.workout_name, 39 | "workoutSegments": [ 40 | { 41 | "segmentOrder": 1, 42 | "sportType": { 43 | "sportTypeId": SPORT_TYPES[self.sport_type], 44 | "sportTypeKey": self.sport_type, 45 | }, 46 | "workoutSteps": [step.garminconnect_json() for step in self.workout_steps], 47 | } 48 | ], 49 | } 50 | 51 | 52 | class WorkoutStep: 53 | def __init__( 54 | self, 55 | order, 56 | step_type, 57 | end_condition="lap.button", 58 | end_condition_value=None, 59 | target=None, 60 | ): 61 | """Valid end condition values: 62 | - distance: '2.0km', '1.125km', '1.6km' 63 | - time: 0:40, 4:20 64 | - lap.button 65 | """ 66 | self.order = order 67 | self.step_type = step_type 68 | self.end_condition = end_condition 69 | self.end_condition_value = end_condition_value 70 | self.target = target or Target() 71 | 72 | def end_condition_unit(self): 73 | if self.end_condition and self.end_condition.endswith("km"): 74 | return {"unitKey": "kilometer"} 75 | else: 76 | return None 77 | 78 | def parsed_end_condition_value(self): 79 | # distance 80 | if self.end_condition_value and self.end_condition_value.endswith("km"): 81 | return int(float(self.end_condition_value.replace("km", "")) * 1000) 82 | 83 | # time 84 | elif self.end_condition_value and ":" in self.end_condition_value: 85 | m, s = [int(x) for x in self.end_condition_value.split(":")] 86 | return m * 60 + s 87 | else: 88 | return None 89 | 90 | def garminconnect_json(self): 91 | return { 92 | "type": "ExecutableStepDTO", 93 | "stepId": None, 94 | "stepOrder": self.order, 95 | "childStepId": None, 96 | "description": None, 97 | "stepType": { 98 | "stepTypeId": STEP_TYPES[self.step_type], 99 | "stepTypeKey": self.step_type, 100 | }, 101 | "endCondition": { 102 | "conditionTypeKey": self.end_condition, 103 | "conditionTypeId": END_CONDITIONS[self.end_condition], 104 | }, 105 | "preferredEndConditionUnit": self.end_condition_unit(), 106 | "endConditionValue": self.parsed_end_condition_value(), 107 | "endConditionCompare": None, 108 | "endConditionZone": None, 109 | **self.target.garminconnect_json(), 110 | } 111 | 112 | 113 | class Target: 114 | def __init__(self, target="no.target", to_value=None, from_value=None, zone=None): 115 | self.target = target 116 | self.to_value = to_value 117 | self.from_value = from_value 118 | self.zone = zone 119 | 120 | def garminconnect_json(self): 121 | return { 122 | "targetType": { 123 | "workoutTargetTypeId": TARGET_TYPES[self.target], 124 | "workoutTargetTypeKey": self.target, 125 | }, 126 | "targetValueOne": self.to_value, 127 | "targetValueTwo": self.from_value, 128 | "zoneNumber": self.zone, 129 | } 130 | -------------------------------------------------------------------------------- /fitlek/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 json as json_lib 10 | import ssl 11 | from base64 import b64encode 12 | from collections import namedtuple 13 | from http.cookiejar import CookieJar 14 | from urllib.error import HTTPError, URLError 15 | from urllib.parse import urlencode 16 | from urllib.request import ( 17 | HTTPCookieProcessor, 18 | HTTPRedirectHandler, 19 | HTTPSHandler, 20 | Request, 21 | build_opener, 22 | ) 23 | 24 | Response = namedtuple("Response", "request content json status url headers cookiejar") 25 | 26 | 27 | class NoRedirect(HTTPRedirectHandler): 28 | def redirect_request(self, req, fp, code, msg, headers, newurl): 29 | return None 30 | 31 | 32 | def request( 33 | url, 34 | params={}, 35 | json=None, 36 | data=None, 37 | headers={}, 38 | method="GET", 39 | verify=True, 40 | redirect=True, 41 | cookiejar=None, 42 | basic_auth=None, 43 | timeout=None, 44 | ): 45 | """ 46 | Returns a (named)tuple with the following properties: 47 | - request 48 | - content 49 | - json (dict; or None) 50 | - headers (dict; all lowercase keys) 51 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 52 | - status 53 | - url (final url, after any redirects) 54 | - cookiejar 55 | """ 56 | method = method.upper() 57 | headers = {k.lower(): v for k, v in headers.items()} # lowecase headers 58 | 59 | if params: 60 | url += "?" + urlencode(params) # build URL from params 61 | if json and data: 62 | raise Exception("Cannot provide both json and data parameters") 63 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 64 | raise Exception("Request method must POST, PATCH or PUT if json or data is provided") 65 | if not timeout: 66 | timeout = 60 67 | 68 | if json: # if we have json, stringify and put it in our data variable 69 | headers["content-type"] = "application/json" 70 | data = json_lib.dumps(json).encode("utf-8") 71 | elif data: 72 | data = urlencode(data).encode() 73 | 74 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 75 | username, password = basic_auth 76 | headers["authorization"] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 77 | 78 | if not cookiejar: 79 | cookiejar = CookieJar() 80 | 81 | ctx = ssl.create_default_context() 82 | if not verify: # ignore ssl errors 83 | ctx.check_hostname = False 84 | ctx.verify_mode = ssl.CERT_NONE 85 | 86 | handlers = [] 87 | handlers.append(HTTPSHandler(context=ctx)) 88 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 89 | 90 | if not redirect: 91 | no_redirect = NoRedirect() 92 | handlers.append(no_redirect) 93 | 94 | opener = build_opener(*handlers) 95 | req = Request(url, data=data, headers=headers, method=method) 96 | 97 | try: 98 | with opener.open(req, timeout=timeout) as resp: 99 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 100 | headers = {k.lower(): v for k, v in list(resp.info().items())} 101 | 102 | if "gzip" in headers.get("content-encoding", ""): 103 | content = gzip.decompress(content) 104 | 105 | json = ( 106 | json_lib.loads(content) 107 | if "application/json" in headers.get("content-type", "").lower() and content 108 | else None 109 | ) 110 | except HTTPError as e: 111 | status, content, resp_url = (e.code, e.read(), e.geturl()) 112 | headers = {k.lower(): v for k, v in list(e.headers.items())} 113 | 114 | if "gzip" in headers.get("content-encoding", ""): 115 | content = gzip.decompress(content) 116 | 117 | json = ( 118 | json_lib.loads(content) 119 | if "application/json" in headers.get("content-type", "").lower() and content 120 | else None 121 | ) 122 | 123 | return Response(req, content, json, status, resp_url, headers, cookiejar) 124 | 125 | 126 | import unittest # noqa: E402 127 | 128 | 129 | class RequestTestCase(unittest.TestCase): 130 | def test_cannot_provide_json_and_data(self): 131 | with self.assertRaises(Exception): 132 | request( 133 | "https://httpbingo.org/post", 134 | json={"name": "Brenton"}, 135 | data="This is some form data", 136 | ) 137 | 138 | def test_should_fail_if_json_or_data_and_not_p_method(self): 139 | with self.assertRaises(Exception): 140 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 141 | 142 | with self.assertRaises(Exception): 143 | request("https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD") 144 | 145 | def test_should_set_content_type_for_json_request(self): 146 | response = request("https://httpbingo.org/post", json={"name": "Brenton"}, method="POST") 147 | self.assertEqual(response.request.headers["Content-type"], "application/json") 148 | 149 | def test_should_work(self): 150 | response = request("https://httpbingo.org/get") 151 | self.assertEqual(response.status, 200) 152 | 153 | def test_should_create_url_from_params(self): 154 | response = request( 155 | "https://httpbingo.org/get", 156 | params={"name": "brenton", "library": "tiny-request"}, 157 | ) 158 | self.assertEqual(response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request") 159 | 160 | def test_should_return_headers(self): 161 | response = request("https://httpbingo.org/response-headers", params={"Test-Header": "value"}) 162 | self.assertEqual(response.headers["test-header"], "value") 163 | 164 | def test_should_populate_json(self): 165 | response = request("https://httpbingo.org/json") 166 | self.assertTrue("slideshow" in response.json) 167 | 168 | def test_should_return_response_for_404(self): 169 | response = request("https://httpbingo.org/404") 170 | self.assertEqual(response.status, 404) 171 | self.assertTrue("text/plain" in response.headers["content-type"]) 172 | 173 | def test_should_fail_with_bad_ssl(self): 174 | with self.assertRaises(URLError): 175 | request("https://expired.badssl.com/") 176 | 177 | def test_should_load_bad_ssl_with_verify_false(self): 178 | response = request("https://expired.badssl.com/", verify=False) 179 | self.assertEqual(response.status, 200) 180 | 181 | def test_should_form_encode_non_json_post_requests(self): 182 | response = request("https://httpbingo.org/post", data={"name": "test-user"}, method="POST") 183 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 184 | 185 | def test_should_follow_redirect(self): 186 | response = request( 187 | "https://httpbingo.org/redirect-to", 188 | params={"url": "https://duckduckgo.com/"}, 189 | ) 190 | self.assertEqual(response.url, "https://duckduckgo.com/") 191 | self.assertEqual(response.status, 200) 192 | 193 | def test_should_not_follow_redirect_if_redirect_false(self): 194 | response = request( 195 | "https://httpbingo.org/redirect-to", 196 | params={"url": "https://duckduckgo.com/"}, 197 | redirect=False, 198 | ) 199 | self.assertEqual(response.status, 302) 200 | 201 | def test_cookies(self): 202 | response = request( 203 | "https://httpbingo.org/cookies/set", 204 | params={"cookie": "test"}, 205 | redirect=False, 206 | ) 207 | response = request("https://httpbingo.org/cookies", cookiejar=response.cookiejar) 208 | self.assertEqual(response.json["cookie"], "test") 209 | 210 | def test_basic_auth(self): 211 | response = request("http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd")) 212 | self.assertEqual(response.json["authorized"], True) 213 | 214 | def test_should_handle_gzip(self): 215 | response = request("http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"}) 216 | self.assertEqual(response.json["gzipped"], True) 217 | 218 | def test_should_timeout(self): 219 | import socket 220 | 221 | with self.assertRaises((TimeoutError, socket.timeout)): 222 | request("http://httpbingo.org/delay/3", timeout=1) 223 | 224 | def test_should_handle_head_requests(self): 225 | response = request("http://httpbingo.org/head", method="HEAD") 226 | self.assertTrue(response.content == b"") 227 | --------------------------------------------------------------------------------