HTTP заявки с requests#

HTTP#

Hyper-text Transfer Protocol е протокол за трансфер на информация (в приложния слой на OSI модела), който е стандарт за комуникация в мрежата.

Версии:

  • HTTP/1 - от 1996г.

  • HTTP/2 - от 2015г.

  • HTTP/3 - от 2022г.

Примерна HTTP заявка:

GET / HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

Винаги първия ред е във формат {HTTP метода} {URI} HTTP/{версия}. В случая искаме да вземем ресурсът, намиращ се на / (т.е. root-a на example.com) с GET метода.

След него на всеки ред стои 1 хедър (заглавие) във формат {име}: {стойност}

Примерен HTTP отговор на заявка:

HTTP/1.1 200 OK
Date: Mon, 23 May 2005 22:38:34 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 155
Last-Modified: Wed, 08 Jan 2003 23:11:55 GMT
Server: Apache/1.3.3.7 (Unix) (Red-Hat/Linux)
ETag: "3f80f-1b6-3e1cb03b"
Accept-Ranges: bytes
Connection: close

<html>
  <head>
    <title>An Example Page</title>
  </head>
  <body>
    <p>Hello World, this is a very simple HTML document.</p>
  </body>
</html>

Пърият ред винаги е във формат HTTP/{версия} {статус код} {има на статус кода}. В случая той е “200 OK”. (лист с всички кодове: тук)

След това са хедърите, след които има празен ред и започва тялото на отговора. В случая това е HTML страница, “намираща” се на example.com.

Методи#

  • GET (взимане на ресурс) (заявката може да няма тяло)

  • POST (изпращане на ресурс) (заявката има тяло)

  • PUT (заменяне на ресурс) (заявката има тяло)

  • PATCH (промяна на част от ресурс) (заявката има тяло)

  • DELETE (изтриване на ресурс) (заявката може да няма тяло)

  • HEAD (взимане само на хедърите на ресурс (като GET, но без върнато тяло))

  • OPTIONS (взимане на методите, поддържани от ресурса) (заявката може да няма тяло)

Статус кодове#

  • 200 - 299: Успешно изпълнена заявка

  • 300 - 399: Пренасочване

  • 400 - 499: Грешка на клиента

  • 500 - 599: Грешка на сървъра

лист с всички

Добавяне на requests#

Библиотеката requests не е вградена в езика (repo: psf/requests), затова трябва да се инсталира допълнително.

Чрез package manager-a pip това става с командата:

pip install requests  # sometimes pip3 is the right one though
!pip install requests
Collecting requests
  Downloading requests-2.28.1-py3-none-any.whl (62 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 62.8/62.8 kB 1.2 MB/s eta 0:00:00[36m0:00:01
?25hCollecting idna<4,>=2.5
  Downloading idna-3.4-py3-none-any.whl (61 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 61.5/61.5 kB 1.6 MB/s eta 0:00:00
?25hCollecting urllib3<1.27,>=1.21.1
  Downloading urllib3-1.26.13-py2.py3-none-any.whl (140 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 140.6/140.6 kB 2.6 MB/s eta 0:00:00[31m4.5 MB/s eta 0:00:01
?25hCollecting certifi>=2017.4.17
  Downloading certifi-2022.9.24-py3-none-any.whl (161 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 161.1/161.1 kB 3.0 MB/s eta 0:00:00 MB/s eta 0:00:01
?25hCollecting charset-normalizer<3,>=2
  Downloading charset_normalizer-2.1.1-py3-none-any.whl (39 kB)
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests
Successfully installed certifi-2022.9.24 charset-normalizer-2.1.1 idna-3.4 requests-2.28.1 urllib3-1.26.13

След това трябва да се включи в текущия модул чрез import requests:

import requests

Методи#

В модула има по една функция за всеки един HTTP метод. Всички те са с еднаква сигнатура, която по подразбиране изисква само един аргумент - URL адреса на ресурса.

requests.get("https://fmi.uni-sofia.bg")
<Response [200]>
requests.post("https://google.com")
<Response [405]>
requests.delete("https://tesla.com")  # cancel Musk
<Response [501]>

Всеки един от тези методи връщат Response обект, в който се съдържа целия отговор.

Проверка дали заявката е успешна#

Статус кода на отговора можем да вземем чрез status_code атрибута (тип int):

for url in [
    "https://httpbin.org/status/204",
    "https://httpbin.org/status/404",
]:
    response = requests.get(url)

    if response.status_code in range(200, 400):
        print("Request went well. Status code = ", response.status_code)
    else:
        print("Sum Ting Went Wong. Status code = ", response.status_code)
Request went well. Status code =  204
Sum Ting Went Wong. Status code =  404

Горния начин на проверка обаче е често срещан - всички 2хх и 3хх статус кодове означават, че грешка на клиента или съвръра не е имало. Затова Response обекта има предефиниран __bool__, който оценява self по същия начин като в горното сравнение:

for url in [
    "https://httpbin.org/status/204",
    "https://httpbin.org/status/404",
]:
    response = requests.get(url)

    if response:
        print("Request went well. Status code = ", response.status_code)
    else:
        print("Sum Ting Went Wong. Status code = ", response.status_code)
Request went well. Status code =  204
Sum Ting Went Wong. Status code =  404

Ако пък искаме да работим с изключения, може да извикаме raise_for_status, което би хвърлило HTTPError ако не е успешна заявката:

from requests.exceptions import HTTPError

for url in [
    "https://httpbin.org/status/204",
    "https://httpbin.org/status/404",
    "example.com",  # thisis invalid so it will not even get to the `.raise_for_status()`
]:
    try:
        response = requests.get(url)
        response.raise_for_status()
    
    except HTTPError as http_err:
        print(f'HTTP error occurred: {http_err}')

    except Exception as err:
        print(f'Other error occurred: {err}')
    
    else:
        print('Success!')
Success!
HTTP error occurred: 404 Client Error: NOT FOUND for url: https://httpbin.org/status/404
Other error occurred: Invalid URL 'example.com': No scheme supplied. Perhaps you meant http://example.com?

Взимане на хедърите и съдържанието от отговора#

response = requests.get("https://httpbin.org/json")
response
<Response [200]>

Заглавните части (хедърите) са в headers, което връща обект, подобен на dict[str, str], но с case-insensitive ключове:

response.headers
{'Date': 'Mon, 28 Nov 2022 01:08:13 GMT', 'Content-Type': 'application/json', 'Content-Length': '429', 'Connection': 'keep-alive', 'Server': 'gunicorn/19.9.0', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true'}
response.headers["CONTENT-TYPE"]
'application/json'

content връща тялото като bytes:

response.content
b'{\n  "slideshow": {\n    "author": "Yours Truly", \n    "date": "date of publication", \n    "slides": [\n      {\n        "title": "Wake up to WonderWidgets!", \n        "type": "all"\n      }, \n      {\n        "items": [\n          "Why <em>WonderWidgets</em> are great", \n          "Who <em>buys</em> WonderWidgets"\n        ], \n        "title": "Overview", \n        "type": "all"\n      }\n    ], \n    "title": "Sample Slide Show"\n  }\n}\n'

Ако очакваме да е текстово съдържанието, можем да ползваме и text, за да ги конвертираме в str. По подразбиране encoding-ът е “utf-8”, може да се променя от едноименния атрибут.

print(response.text)  # print will just prettify the output
{
  "slideshow": {
    "author": "Yours Truly", 
    "date": "date of publication", 
    "slides": [
      {
        "title": "Wake up to WonderWidgets!", 
        "type": "all"
      }, 
      {
        "items": [
          "Why <em>WonderWidgets</em> are great", 
          "Who <em>buys</em> WonderWidgets"
        ], 
        "title": "Overview", 
        "type": "all"
      }
    ], 
    "title": "Sample Slide Show"
  }
}

JSON като формат за пренос на данни е най широко-използвания сред HTTP услугите поради леснотата на работата с него. В случая отговора е точно в такъв вид и можем да използваме и помощния метод json(), който ни връща отговора като dict (или list, е зависимост от обекта, който седи като корен на JSON-a):

response.json()
{'slideshow': {'author': 'Yours Truly',
  'date': 'date of publication',
  'slides': [{'title': 'Wake up to WonderWidgets!', 'type': 'all'},
   {'items': ['Why <em>WonderWidgets</em> are great',
     'Who <em>buys</em> WonderWidgets'],
    'title': 'Overview',
    'type': 'all'}],
  'title': 'Sample Slide Show'}}

Параметри на методите за заявката#

Можем да видим какво се съдържа в заявката с обекта върнат от .request:

response.request
<PreparedRequest [GET]>
response.request.url
'https://httpbin.org/json'
response.request.headers
{'User-Agent': 'python-requests/2.28.1', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive'}
response.request.body

С params задавaме query parameters - параметри на заявката. Те задължително трябва да бъдат подавани като dict[str, str] (може още като list[tuple[str, str]] или директно като bytes) като стойност на именования аргумент params:

requests.get(
    "https://zamunda.net/catalogs/tv",
    params={
        "search": "Mr+Robot",
        "field": "name",
        "comb": "yes",
    }
)
<Response [200]>

Note: В GET заявките те биват слепени към края на URL-a след ? във формат name1=value1&name2=value2&...&name=value. За останалите HTTP методи обаче това не е в сила.

За тези, които поддържат добавянето на данни в заявката, това става чрез аргумента data:

requests.post('https://httpbin.org/post', data={'key':'value'})
<Response [200]>

Ако искаме да изпратим допълнителни хедъри, задаваме стойност на аргумента headers:

response = requests.get(
    "https://api.github.com/search/repositories",
    params={"q": "42+language:python"},
    headers={"Accept": "application/vnd.github.v3.text-match+json"}  
    # ^ така казваме на Github сървъра, че искаме да ни върне и text-matches списък от обекти
    # за всяко намерено репозитори
)

response.json()["items"][0]["text_matches"]
[{'object_url': 'https://api.github.com/repositories/10340514',
  'object_type': 'Repository',
  'property': 'description',
  'fragment': 'Modules to convert numbers to words. 42 --> forty-two',
  'matches': [{'text': '42', 'indices': [37, 39]}]}]

Ако не искаме автоматичното следване на редиректи, можем да го изключим с allow_redirects=False:

url = "https://zamunda.net/catalogs/tv"
params={
    "search": "Mr+Robot",
    "field": "name",
    "comb": "yes",
}


resp = requests.get(url, params=params)
print(resp.url)

resp = requests.get(url, params=params, allow_redirects=False)
print(resp.url)
https://zamunda.net/login.php?returnto=%2Fcatalogs%2Ftv%3Fsearch%3DMr%252BRobot%26field%3Dname%26comb%3Dyes
https://zamunda.net/catalogs/tv?search=Mr%2BRobot&field=name&comb=yes

Ако не ни трябва проверка за SSL сертификати, задаваме verify=False:

requests.get('https://api.github.com', verify=False)
/opt/homebrew/lib/python3.10/site-packages/urllib3/connectionpool.py:1045: InsecureRequestWarning: Unverified HTTPS request is being made to host 'api.github.com'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html#ssl-warnings
  warnings.warn(
<Response [200]>

По подразбиране методите могат да чакат за отговор неопределено време, така че винаги е хубаво да сложим и timeout. При прехвърляне на зададеното максимално време се хвърля Timeout изключение:

from requests.exceptions import Timeout

susi = "https://susi.uni-sofia.bg/ISSU/forms/students/ElectiveDisciplinesSubscribe.aspx"
try:
    r = requests.get(susi, timeout=0.05)  # в секунди; нарочно толкова малък, за да хвърлим грешка
except Timeout:
    print("СУСИ ке падне")
else:
    print("Абсурд, записах избираемите")
СУСИ ке падне

JSON#

Ако тялото на съобщението, което искаме да пратим е във вид на JSON, то вместо data за удобство можем да ползваме json аргумента, подавайки dict:

response = requests.post('https://httpbin.org/post', json={'key':'value'})
response.json()['data']
'{"key": "value"}'

За работа с JSON обекти имаме вградената библиотека json. С json.dumps можем да превърнем JSON dict стринг, а с json.loads - обратното:

import json
fetched_json_str = """
{
    "name": "亚历山大",
    "age": 24.420,
    "hobbies": [
        "editing text files",
        "sh*tposting music",
        "drinking craft beer",
        "creating existential crisises"
    ],
    "sorrow": true,
    "purpose": null
}
"""
json_dict = json.loads(fetched_json_str)
print("loads -> ,", json_dict)
print("dumps -> ", json.dumps(json_dict))  # back to a string
loads -> , {'name': '亚历山大', 'age': 24.42, 'hobbies': ['editing text files', 'sh*tposting music', 'drinking craft beer', 'creating existential crisises'], 'sorrow': True, 'purpose': None}
dumps ->  {"name": "\u4e9a\u5386\u5c71\u5927", "age": 24.42, "hobbies": ["editing text files", "sh*tposting music", "drinking craft beer", "creating existential crisises"], "sorrow": true, "purpose": null}

С dump и load пък може да запишем/прочетем JSON директно от файл. Oсвен това, има начини за сериализация/десериализация на custom обекти чрез JSONEncoder и JSONDecoder. Повече информация тук.

Автентикация#

Общоприето е удостоверянето на самоличността да се случва най-често през хедъра Authentication, пращан от клиентите. Стойността му е от вид “{тип на token} {token}”, като типовете могат да са Basic, Bearer и др. Със създаването му ни улеснява auth параметъра:

r = requests.get("https://api.github.com/user", auth=("exampleuser", "examplepassword"))
r.request.headers["Authorization"]
'Basic ZXhhbXBsZXVzZXI6ZXhhbXBsZXBhc3N3b3Jk'

По подразбиране видът на удостоверяване е Basic, т.е. token-ът представлява просто base64-енкодираният стринг "{името}:{паролата}".

import base64

token = r.request.headers["Authorization"].split()[1]
decoded = base64.b64decode(token).decode("utf-8")

print(f"{token} is actually {decoded}")
ZXhhbXBsZXVzZXI6ZXhhbXBsZXBhc3N3b3Jk is actually exampleuser:examplepassword

Можем да ползваме и AuthBase наследник, който да подадем като стойност на auth. По подразбиране се използва HTTPBasicAuth:

from requests.auth import HTTPBasicAuth

r = requests.get(
    "https://api.github.com/user",
    auth=HTTPBasicAuth("exampleuser", "examplepassword")
)
r.request.headers["Authorization"]
'Basic ZXhhbXBsZXVzZXI6ZXhhbXBsZXBhc3N3b3Jk'

Можем и собствени начини на автентикация да дефинираме чрез наследяване. Тогава трябва задължително в __call__ да променим дадения Request така, че да има необходимите хедъри:

from requests import PreparedRequest
from requests.auth import AuthBase

class CustomAuth(AuthBase):
    """Implements a custom authentication scheme."""

    def __init__(self, token: str):
        self.token = token

    def __call__(self, r: PreparedRequest) -> PreparedRequest:
        """Attach an API token to a custom auth header."""
        r.headers['X-TokenAuth'] = f"{self.token}"
        return r


r = requests.get('https://httpbin.org/get', auth=CustomAuth('deadbeef'))
r.request.headers
{'User-Agent': 'python-requests/2.28.1', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'X-TokenAuth': 'deadbeef'}

Сесии#

Потребителските сесии се използват за да се запазят настройки през различните заявки. Например ако искаме един и същ начин на оторизация да се използва за няколко заявки:

import requests

with requests.Session() as session:
    session.auth = ("username", "password")

    response = session.get('https://api.github.com/user')
    print(response.request.headers)
    
    response = session.patch('https://api.github.com/user')
    print(response.request.headers)
{'User-Agent': 'python-requests/2.28.1', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Authorization': 'Basic dXNlcm5hbWU6cGFzc3dvcmQ='}
{'User-Agent': 'python-requests/2.28.1', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Length': '0', 'Authorization': 'Basic dXNlcm5hbWU6cGFzc3dvcmQ='}

Адаптери#

За да добавим допълнителна функционалност/логика към всяка заявка за конкретна услуга, можем да използваме собствен TransportAdapter, който да закачим към сесията. В request идва вграден един такъв, наречен HttpAdapter, с който можем например да дефинираме колко пъти да опитаме отново да изпратим заявката, в случай че е неуспешна:

from requests.adapters import HTTPAdapter
from requests.exceptions import ConnectionError

github_adapter = HTTPAdapter(max_retries=3)

with requests.Session() as session:
    session.mount('https://api.github.com', github_adapter)

    try:
        session.get('https://api.github.com')
    except ConnectionError as ce:
        print(ce)

Неблокиращи решения#

Извикването на get, post и т.н. блокира изпълнението на програмата, докато content не се свали. В случай, че се търси по-асинхронен подход, то съществуват билбиотеки като requests-threads, grequests, httpx и др.