├── pyproject.toml ├── Pipfile ├── omglol.py ├── LICENSE ├── README.md ├── .github └── workflows │ └── ci.yml ├── generate.py └── thttp.py /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /omglol.py: -------------------------------------------------------------------------------- 1 | from thttp import request 2 | 3 | 4 | def update_now_page(username, content, api_key): 5 | response = request( 6 | f"https://api.omg.lol/address/{username}/now", 7 | method="POST", 8 | json={"content": content}, 9 | headers={"Authorization": f"Bearer {api_key}"}, 10 | ) 11 | print(response.json) 12 | 13 | 14 | def get_now_page(username): 15 | response = request(f"https://api.omg.lol/address/{username}/now") 16 | return response.json["response"]["now"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Brenton Cleeland 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This script generates an [omg.lol](https://omg.lol) [/now](https://nownownow.com/about) block containing running stats from [Intervals.icu](https://intervals.icu). 2 | It's possible that the Venn diagram of users this is useful for only contains me. 3 | 4 | Currently only metric units are supported. 5 | 6 | 7 | ## Usage 8 | 9 | There are four environment variables that need to be configured to run the sync: 10 | 11 | - `INTERVALS_ATHLETE_ID` + `INTERVALS_API_KEY` are available in the "Developer Settings" section of the Intervals settings 12 | - `OMGLOL_USERNAME`, `OMGLOL_API_KEY` are available in the Account section of the omg.lol dashboard 13 | 14 | Optionally you can include `` and `` in your /now page's Markdown to specify when you want your Intervals stats to go. 15 | If this doesn't exist the block will be added at the end of your page. 16 | 17 | Once the environment variables are available, running the script is as simple as: 18 | 19 | ``` 20 | > python generate.py 21 | ``` 22 | 23 | Python versions >= 3.6 should be supported. 24 | 25 | 26 | ### Usage as a Github Action 27 | 28 | Simply fork this repository and add the environment variables to update you page with Github Actions. 29 | The simplest configuration involves settings up the four environment variables as Repository Secrets in the Github settings for your fork of this repository. 30 | 31 | 32 | ## Contributing 33 | 34 | You are welcome to submit Pull Requests or raise issues. 35 | All CI steps will need to be passing before PRs are merged. 36 | You can run them locally with: 37 | 38 | ``` 39 | > isort . && black . && ruff . && bandit -r . 40 | ``` 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Python Checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: "0 */3 * * *" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | black: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: "3.11" 23 | 24 | - name: Install black 25 | run: | 26 | python -m pip install black 27 | 28 | - name: Run black 29 | run: | 30 | black --check . 31 | 32 | isort: 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v3 37 | 38 | - name: Set up Python 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: "3.11" 42 | 43 | - name: Install isort 44 | run: | 45 | python -m pip install isort 46 | 47 | - name: Run isort 48 | run: | 49 | isort --check . 50 | 51 | ruff: 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - uses: actions/checkout@v3 56 | 57 | - name: Set up Python 58 | uses: actions/setup-python@v4 59 | with: 60 | python-version: "3.11" 61 | 62 | - name: Install ruff 63 | run: | 64 | python -m pip install ruff 65 | 66 | - name: Run ruff 67 | run: | 68 | ruff --output-format=github . 69 | 70 | bandit: 71 | runs-on: ubuntu-latest 72 | 73 | steps: 74 | - uses: actions/checkout@v3 75 | 76 | - name: Set up Python 77 | uses: actions/setup-python@v4 78 | with: 79 | python-version: 3 80 | 81 | - name: Install bandit 82 | run: | 83 | python -m pip install bandit[toml] 84 | 85 | - name: Run bandit scan 86 | run: | 87 | bandit -r . 88 | 89 | update-page: 90 | needs: [black, ruff, bandit, isort] 91 | if: ${{ success() && github.ref == 'refs/heads/main' }} 92 | runs-on: ubuntu-latest 93 | 94 | steps: 95 | - uses: actions/checkout@v3 96 | with: 97 | submodules: recursive 98 | 99 | - uses: actions/setup-python@v4 100 | with: 101 | python-version: '3.10' 102 | 103 | - name: Run generate 104 | run: | 105 | python generate.py 106 | env: 107 | INTERVALS_API_KEY: ${{ secrets.INTERVALS_API_KEY }} 108 | INTERVALS_ATHLETE_ID: ${{ secrets.INTERVALS_ATHLETE_ID }} 109 | OMGLOL_API_KEY: ${{ secrets.OMGLOL_API_KEY }} 110 | OMGLOL_USERNAME: ${{ secrets.OMGLOL_USERNAME }} 111 | -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import datetime, timedelta 4 | 5 | import omglol 6 | from thttp import request 7 | 8 | try: 9 | intervals_id = os.environ["INTERVALS_ATHLETE_ID"] 10 | intervals_api_key = os.environ["INTERVALS_API_KEY"] 11 | 12 | omg_lol_username = os.environ["OMGLOL_USERNAME"] 13 | omg_lol_key = os.environ["OMGLOL_API_KEY"] 14 | except KeyError: 15 | print("All environment variables must be configured") 16 | sys.exit(1) 17 | 18 | 19 | def format_mins_seconds(d): 20 | d = int(d) 21 | minutes, seconds = divmod(d, 60) 22 | return f"{minutes:02}:{seconds:02}" 23 | 24 | 25 | def get_most_recent_run(): 26 | today = datetime.now() 27 | url = f"https://intervals.icu/api/v1/athlete/{intervals_id}/activities" 28 | 29 | response = request( 30 | url, 31 | params={ 32 | "oldest": (today - timedelta(days=20)).isoformat().split("T")[0], 33 | "newest": (today + timedelta(days=1)).isoformat().split("T")[0], 34 | }, 35 | basic_auth=("API_KEY", intervals_api_key), 36 | ) 37 | 38 | runs = [x for x in response.json if x["type"] == "Run"] 39 | return runs[0] 40 | 41 | 42 | def get_actual_kms(week_offset=0): 43 | today = datetime.now() + timedelta(weeks=week_offset) 44 | target_week = today.isocalendar().week 45 | 46 | url = f"https://intervals.icu/api/v1/athlete/{intervals_id}/activities" 47 | 48 | response = request( 49 | url, 50 | params={ 51 | "oldest": (today - timedelta(days=20)).isoformat().split("T")[0], 52 | "newest": (today + timedelta(days=120)).isoformat().split("T")[0], 53 | }, 54 | basic_auth=("API_KEY", intervals_api_key), 55 | ) 56 | 57 | current_week_volume = [] 58 | 59 | for event in response.json: 60 | start_date = datetime.fromisoformat(event["start_date_local"]) 61 | event_week = start_date.isocalendar().week 62 | 63 | if event_week != target_week: 64 | continue 65 | 66 | if event.get("type", "") == "Run" and event.get("distance"): 67 | current_week_volume.append(event["distance"]) 68 | 69 | return int(sum(current_week_volume) / 1000) 70 | 71 | 72 | def get_planned_week(week_offset=0): 73 | today = datetime.now() + timedelta(weeks=week_offset) 74 | target_week = today.isocalendar().week 75 | 76 | url = f"https://intervals.icu/api/v1/athlete/{intervals_id}/events" 77 | 78 | response = request( 79 | url, 80 | params={ 81 | "oldest": (today - timedelta(days=7)).isoformat().split("T")[0], 82 | "newest": (today + timedelta(days=120)).isoformat().split("T")[0], 83 | }, 84 | basic_auth=("API_KEY", intervals_api_key), 85 | ) 86 | 87 | current_week_planned = {x: [] for x in range(7)} 88 | 89 | for event in response.json: 90 | start_date = datetime.fromisoformat(event["start_date_local"]) 91 | event_week = start_date.isocalendar().week 92 | 93 | if event_week != target_week: 94 | continue 95 | 96 | day_of_week = start_date.weekday() 97 | if event.get("type", "") == "Run" and event.get("distance"): 98 | current_week_planned[day_of_week].append(event["distance"]) 99 | 100 | return current_week_planned 101 | 102 | 103 | def get_target_kms(week_offset=0): 104 | current_week_planned = get_planned_week(week_offset) 105 | target_meters = sum([sum(v) for v in current_week_planned.values()]) 106 | return int(target_meters / 1000) 107 | 108 | 109 | def get_upcoming_races(): 110 | today = datetime.now() 111 | url = f"https://intervals.icu/api/v1/athlete/{intervals_id}/events" 112 | 113 | response = request( 114 | url, 115 | params={ 116 | "oldest": today.isoformat().split("T")[0], 117 | "newest": (today + timedelta(days=365)).isoformat().split("T")[0], 118 | }, 119 | basic_auth=("API_KEY", intervals_api_key), 120 | ) 121 | 122 | runs = [x for x in response.json if x["type"] == "Run" and x["category"].startswith("RACE_")] 123 | return runs 124 | 125 | 126 | def get_yearly_stats(): 127 | today = datetime.now() 128 | 129 | today.isocalendar().week 130 | 131 | url = f"https://intervals.icu/api/v1/athlete/{intervals_id}/activities" 132 | 133 | response = request( 134 | url, 135 | params={ 136 | "oldest": f"{today.year - 1}-12-31", 137 | "newest": f"{today.year + 1}-01-01", 138 | }, 139 | basic_auth=("API_KEY", intervals_api_key), 140 | ) 141 | 142 | ytd_kms = [] 143 | ytd_mins = [] 144 | ytd_elevation = [] 145 | 146 | for event in response.json: 147 | start_date = datetime.fromisoformat(event["start_date_local"]) 148 | 149 | if start_date.year == today.year: 150 | if event.get("type", "") == "Run" and event.get("distance"): 151 | ytd_kms.append(event["distance"]) 152 | ytd_mins.append(event["moving_time"] / 60) 153 | ytd_elevation.append(event.get("total_elevation_gain", 0) or 0) 154 | 155 | return ( 156 | len(ytd_kms), 157 | int(sum(ytd_kms) / 1000), 158 | int(sum(ytd_mins)), 159 | int(sum(ytd_elevation)), 160 | ) 161 | 162 | 163 | def intervals( 164 | *, 165 | include_this_week=True, 166 | include_recent_run=True, 167 | include_year_to_date=True, 168 | include_upcoming_races=True, 169 | ): 170 | s = "## 🏃♂️ Running Stats and Goals (via [intervals.icu](https://intervals.icu))\n\n" 171 | 172 | if include_this_week: 173 | target_kms = get_target_kms() 174 | actual_kms = get_actual_kms() 175 | 176 | if target_kms: 177 | s += f"- This week: {actual_kms}/{target_kms}km\n" 178 | else: 179 | s += f"- This week: {actual_kms}km" 180 | 181 | print(s) 182 | 183 | if include_recent_run: 184 | most_recent = get_most_recent_run() 185 | strava_id = most_recent.get("strava_id", "") 186 | distance_km = most_recent["distance"] / 1000 187 | formatted_pace = format_mins_seconds(most_recent["moving_time"] / distance_km) 188 | name = most_recent["name"] 189 | 190 | if strava_id: 191 | strava_url = f"https://strava.com/activities/{strava_id}" 192 | s += f"- Latest Run: [{name}]({strava_url}) ({distance_km:.1f}km @ {formatted_pace} min/km)\n" 193 | else: 194 | s += f"- Latest Run: {name} ({distance_km:.1f}km @ {formatted_pace} min/km)\n" 195 | 196 | if include_year_to_date: 197 | num_runs, distance, duration, elevation = get_yearly_stats() 198 | s += "- Year to Date:\n" 199 | s += f" - {num_runs} runs\n" 200 | s += f" - {distance}km\n" 201 | s += f" - {format_mins_seconds(duration).replace(':', 'h')}m of running\n" 202 | s += f" - {elevation} meters of climbing\n" 203 | 204 | if include_upcoming_races: 205 | upcoming = get_upcoming_races() 206 | 207 | if upcoming: 208 | s += "- Upcoming Events:\n" 209 | 210 | for race in upcoming: 211 | race_cat = race["category"].replace("RACE_", "") 212 | s += f' - ({race_cat}) {race["name"]} ({race["start_date_local"].split("T")[0]})\n' 213 | 214 | return s 215 | 216 | 217 | def md_clean_dupe_blank_lines(md): 218 | prev = None 219 | lines = [] 220 | 221 | for line in md.splitlines(): 222 | if not (prev == "" and line == ""): 223 | lines.append(line) 224 | 225 | prev = line 226 | 227 | return "\n".join(lines) 228 | 229 | 230 | def md_clean_endswith_new_line(md): 231 | if not md.endswith("\n"): 232 | return md + "\n" 233 | 234 | return md 235 | 236 | 237 | if __name__ == "__main__": 238 | start_wrapper = "" 239 | end_wrapper = "" 240 | 241 | intervals_content = intervals( 242 | include_this_week=True, 243 | include_recent_run=True, 244 | include_year_to_date=True, 245 | include_upcoming_races=True, 246 | ) 247 | 248 | now_page = omglol.get_now_page(omg_lol_username) 249 | original_content = now_page["content"] 250 | 251 | if start_wrapper in original_content and end_wrapper in original_content: 252 | before, _ = original_content.split(start_wrapper, 1) 253 | _, after = original_content.split(end_wrapper, 1) 254 | 255 | content = f"{before}{start_wrapper}\n{intervals_content}{end_wrapper}\n{after}" 256 | else: 257 | content = f"{original_content}{start_wrapper}\n{intervals_content}{end_wrapper}" 258 | 259 | content = md_clean_dupe_blank_lines(content) 260 | content = md_clean_endswith_new_line(content) 261 | 262 | if content != original_content: 263 | omglol.update_now_page(omg_lol_username, content, omg_lol_key) 264 | else: 265 | print("Content is the same, skipping update...") 266 | -------------------------------------------------------------------------------- /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 import HTTPStatus 14 | from http.cookiejar import CookieJar 15 | from urllib.error import HTTPError, URLError 16 | from urllib.parse import urlencode 17 | from urllib.request import ( 18 | HTTPCookieProcessor, 19 | HTTPRedirectHandler, 20 | HTTPSHandler, 21 | Request, 22 | build_opener, 23 | ) 24 | 25 | Response = namedtuple("Response", "request content json status url headers cookiejar") 26 | 27 | 28 | class NoRedirect(HTTPRedirectHandler): 29 | def redirect_request(self, req, fp, code, msg, headers, newurl): 30 | return None 31 | 32 | 33 | def request( 34 | url, 35 | params={}, 36 | json=None, 37 | data=None, 38 | headers={}, 39 | method="GET", 40 | verify=True, 41 | redirect=True, 42 | cookiejar=None, 43 | basic_auth=None, 44 | timeout=None, 45 | ): 46 | """ 47 | Returns a (named)tuple with the following properties: 48 | - request 49 | - content 50 | - json (dict; or None) 51 | - headers (dict; all lowercase keys) 52 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 53 | - status 54 | - url (final url, after any redirects) 55 | - cookiejar 56 | """ 57 | method = method.upper() 58 | headers = {k.lower(): v for k, v in headers.items()} # lowercase headers 59 | 60 | if params: 61 | url += "?" + urlencode(params) # build URL from query parameters 62 | 63 | if json and data: 64 | raise Exception("Cannot provide both json and data parameters") 65 | 66 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 67 | raise Exception("Request method must POST, PATCH or PUT if json or data is provided") 68 | 69 | if not timeout: 70 | timeout = 60 71 | 72 | if json: # if we have json, dump it to a string and put it in our data variable 73 | headers["content-type"] = "application/json" 74 | data = json_lib.dumps(json).encode("utf-8") 75 | elif data and not isinstance(data, (str, bytes)): 76 | data = urlencode(data).encode() 77 | elif isinstance(data, str): 78 | data = data.encode() 79 | 80 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 81 | username, password = basic_auth 82 | headers["authorization"] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 83 | 84 | if not cookiejar: 85 | cookiejar = CookieJar() 86 | 87 | ctx = ssl.create_default_context() 88 | if not verify: # ignore ssl errors 89 | ctx.check_hostname = False 90 | ctx.verify_mode = ssl.CERT_NONE 91 | 92 | handlers = [] 93 | handlers.append(HTTPSHandler(context=ctx)) 94 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 95 | 96 | if not redirect: 97 | no_redirect = NoRedirect() 98 | handlers.append(no_redirect) 99 | 100 | opener = build_opener(*handlers) 101 | req = Request(url, data=data, headers=headers, method=method) 102 | 103 | try: 104 | with opener.open(req, timeout=timeout) as resp: 105 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 106 | headers = {k.lower(): v for k, v in list(resp.info().items())} 107 | 108 | if "gzip" in headers.get("content-encoding", ""): 109 | content = gzip.decompress(content) 110 | 111 | json = ( 112 | json_lib.loads(content) 113 | if "application/json" in headers.get("content-type", "").lower() and content 114 | else None 115 | ) 116 | except HTTPError as e: 117 | status, content, resp_url = (e.code, e.read(), e.geturl()) 118 | headers = {k.lower(): v for k, v in list(e.headers.items())} 119 | 120 | if "gzip" in headers.get("content-encoding", ""): 121 | content = gzip.decompress(content) 122 | 123 | json = ( 124 | json_lib.loads(content) 125 | if "application/json" in headers.get("content-type", "").lower() and content 126 | else None 127 | ) 128 | 129 | return Response(req, content, json, status, resp_url, headers, cookiejar) 130 | 131 | 132 | def pretty(response, headers_only=False): 133 | RESET = "\033[0m" 134 | HIGHLIGHT = "\033[34m" 135 | HTTP_STATUSES = {x.value: x.name for x in HTTPStatus} 136 | 137 | # status code 138 | print(HIGHLIGHT + str(response.status) + " " + RESET + HTTP_STATUSES.get(response.status, "")) 139 | 140 | # headers 141 | for k in sorted(response.headers.keys()): 142 | print(HIGHLIGHT + k + RESET + ": " + response.headers[k]) 143 | 144 | if headers_only: 145 | return 146 | 147 | # blank line 148 | print() 149 | 150 | # response body 151 | if response.json: 152 | print(json_lib.dumps(response.json, indent=2)) 153 | else: 154 | print(response.content.decode()) 155 | 156 | 157 | import contextlib # noqa: E402 158 | import unittest # noqa: E402 159 | from io import StringIO # noqa: E402 160 | 161 | 162 | class RequestTestCase(unittest.TestCase): 163 | def test_cannot_provide_json_and_data(self): 164 | with self.assertRaises(Exception): 165 | request( 166 | "https://httpbingo.org/post", 167 | json={"name": "Brenton"}, 168 | data="This is some form data", 169 | ) 170 | 171 | def test_should_fail_if_json_or_data_and_not_p_method(self): 172 | with self.assertRaises(Exception): 173 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 174 | 175 | with self.assertRaises(Exception): 176 | request("https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD") 177 | 178 | def test_should_set_content_type_for_json_request(self): 179 | response = request("https://httpbingo.org/post", json={"name": "Brenton"}, method="POST") 180 | self.assertEqual(response.request.headers["Content-type"], "application/json") 181 | 182 | def test_should_work(self): 183 | response = request("https://httpbingo.org/get") 184 | self.assertEqual(response.status, 200) 185 | 186 | def test_should_create_url_from_params(self): 187 | response = request( 188 | "https://httpbingo.org/get", 189 | params={"name": "brenton", "library": "tiny-request"}, 190 | ) 191 | self.assertEqual(response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request") 192 | 193 | def test_should_return_headers(self): 194 | response = request("https://httpbingo.org/response-headers", params={"Test-Header": "value"}) 195 | self.assertEqual(response.headers["test-header"], "value") 196 | 197 | def test_should_populate_json(self): 198 | response = request("https://httpbingo.org/json") 199 | self.assertTrue("slideshow" in response.json) 200 | 201 | def test_should_return_response_for_404(self): 202 | response = request("https://httpbingo.org/404") 203 | self.assertEqual(response.status, 404) 204 | self.assertTrue("text/plain" in response.headers["content-type"]) 205 | 206 | def test_should_fail_with_bad_ssl(self): 207 | with self.assertRaises(URLError): 208 | request("https://expired.badssl.com/") 209 | 210 | def test_should_load_bad_ssl_with_verify_false(self): 211 | response = request("https://expired.badssl.com/", verify=False) 212 | self.assertEqual(response.status, 200) 213 | 214 | def test_should_form_encode_non_json_post_requests(self): 215 | response = request("https://httpbingo.org/post", data={"name": "test-user"}, method="POST") 216 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 217 | 218 | def test_should_follow_redirect(self): 219 | response = request( 220 | "https://httpbingo.org/redirect-to", 221 | params={"url": "https://example.org/"}, 222 | ) 223 | self.assertEqual(response.url, "https://example.org/") 224 | self.assertEqual(response.status, 200) 225 | 226 | def test_should_not_follow_redirect_if_redirect_false(self): 227 | response = request( 228 | "https://httpbingo.org/redirect-to", 229 | params={"url": "https://example.org/"}, 230 | redirect=False, 231 | ) 232 | self.assertEqual(response.status, 302) 233 | 234 | def test_cookies(self): 235 | response = request( 236 | "https://httpbingo.org/cookies/set", 237 | params={"cookie": "test"}, 238 | redirect=False, 239 | ) 240 | response = request("https://httpbingo.org/cookies", cookiejar=response.cookiejar) 241 | self.assertEqual(response.json["cookie"], "test") 242 | 243 | def test_basic_auth(self): 244 | response = request("http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd")) 245 | self.assertEqual(response.json["authorized"], True) 246 | 247 | def test_should_handle_gzip(self): 248 | response = request("http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"}) 249 | self.assertEqual(response.json["gzipped"], True) 250 | 251 | def test_should_handle_gzip_error(self): 252 | response = request("http://httpbingo.org/status/418", headers={"Accept-Encoding": "gzip"}) 253 | self.assertEqual(response.content, b"I'm a teapot!") 254 | 255 | def test_should_timeout(self): 256 | import socket 257 | 258 | with self.assertRaises((TimeoutError, socket.timeout)): 259 | request("http://httpbingo.org/delay/3", timeout=1) 260 | 261 | def test_should_handle_head_requests(self): 262 | response = request("http://httpbingo.org/head", method="HEAD") 263 | self.assertTrue(response.content == b"") 264 | 265 | def test_should_post_data_string(self): 266 | response = request( 267 | "https://ntfy.sh/thttp-test-ntfy", 268 | data="The thttp test suite was executed!", 269 | method="POST", 270 | ) 271 | self.assertTrue(response.json["topic"] == "thttp-test-ntfy") 272 | 273 | def test_pretty_output(self): 274 | response = request("https://basehtml.xyz") 275 | 276 | f = StringIO() 277 | with contextlib.redirect_stdout(f): 278 | pretty(response) 279 | 280 | f.seek(0) 281 | output = f.read() 282 | 283 | self.assertTrue("text/html; charset=utf-8" in output) 284 | self.assertTrue("