Current status
This commit is contained in:
commit
a45f20e87b
16 changed files with 734 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
config.ini
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
26
.gitlab-ci.yml
Normal file
26
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,26 @@
|
|||
image: python:3.10
|
||||
|
||||
stages:
|
||||
- test
|
||||
- publish
|
||||
|
||||
before_script:
|
||||
- python -V
|
||||
- python -m venv venv
|
||||
- source venv/bin/activate
|
||||
- pip install -U pip
|
||||
- pip install .
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- python -m unittest tests/public.py
|
||||
|
||||
publish:
|
||||
stage: publish
|
||||
script:
|
||||
- pip install -U hatchling twine build
|
||||
- python -m build .
|
||||
- python -m twine upload --username __token__ --password ${PYPI_TOKEN} dist/*
|
||||
only:
|
||||
- tags
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2023 Kumi Mitterer <postat@kumi.email>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
36
README.md
Normal file
36
README.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Python wrapper for Austrian Post web services
|
||||
|
||||
This is a Python wrapper for web services provided by [Austrian Post](https://www.post.at/).
|
||||
|
||||
Currently, it only supports looking up shipment details by tracking number.
|
||||
|
||||
Groundwork for services requiring authentication is laid out, but not working
|
||||
yet. If you want to contribute, please feel free to do so.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install git+https://kumig.it/kumitterer/postat.git
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from postat.classes.api import PostAPI
|
||||
|
||||
api = PostAPI()
|
||||
|
||||
# Get shipment details
|
||||
|
||||
shipment_details = api.get_shipment_details("123456789012345678901234567890")
|
||||
|
||||
shipment = shipment["data"]["einzelsendung"]
|
||||
|
||||
# Get latest event
|
||||
|
||||
latest_event = shipment["sendungsEvents"][-1]
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
3
config.dist.ini
Normal file
3
config.dist.ini
Normal file
|
@ -0,0 +1,3 @@
|
|||
[POST]
|
||||
Username = email@add.ress
|
||||
Password = v3rys3cr3t!
|
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
|
@ -0,0 +1,23 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "postat"
|
||||
version = "0.9.0"
|
||||
authors = [
|
||||
{ name="Kumi Mitterer", email="postat@kumi.email" },
|
||||
]
|
||||
description = "Simple Python wrapper to fetch data from Austrian Post (post.at)"
|
||||
readme = "README.md"
|
||||
license = { file="LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://kumig.it/kumitterer/postat"
|
||||
"Bug Tracker" = "https://kumig.it/kumitterer/postat/issues"
|
0
src/postat/__init__.py
Normal file
0
src/postat/__init__.py
Normal file
0
src/postat/classes/__init__.py
Normal file
0
src/postat/classes/__init__.py
Normal file
452
src/postat/classes/api.py
Normal file
452
src/postat/classes/api.py
Normal file
|
@ -0,0 +1,452 @@
|
|||
from typing import Optional, Dict
|
||||
from http.client import HTTPResponse
|
||||
from http.cookies import SimpleCookie
|
||||
from random import SystemRandom
|
||||
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
|
||||
from .http import HTTPRequest
|
||||
from .parser import FormParser
|
||||
|
||||
HOME_URL = "https://www.post.at/"
|
||||
LOGIN_INIT_URL = "https://www.post.at/identity/externallogin?authenticationType=post.azureADB2C&ReturnUrl=%2fidentity%2fexternallogincallback%3fReturnUrl%3dhttps%253a%252f%252fwww.post.at%252fen%26sc_site%3dPostAT%26authenticationSource%3dDefault&sc_site=PostAT"
|
||||
LOGIN_SELF_ASSERTED = "https://login.post.at%(tenant)s/SelfAsserted"
|
||||
LOGIN_CONFIRMED = "https://login.post.at%(tenant)s/api/%(api)s/confirmed"
|
||||
OAUTH_BASE = "https://login.post.at/%(tenant)s/%(api)s/oauth2/v2.0"
|
||||
TOKEN = OAUTH_BASE + "token"
|
||||
AUTHORIZE = OAUTH_BASE + "authorize"
|
||||
GRAPHQL_AUTHENTICATED = "https://api.post.at/sendungen/sv/graphqlAuthenticated"
|
||||
GRAPHQL_PUBLIC = "https://api.post.at/sendungen/sv/graphqlPublic"
|
||||
|
||||
|
||||
class PostAPI:
|
||||
""" A class providing a pseudo-API for the Austrian Post website. """
|
||||
|
||||
def __init__(self, username: Optional[str] = None, password: Optional[str] = None, login: bool = True):
|
||||
""" Initialize the connection to the Austrian Post website.
|
||||
|
||||
Parameters:
|
||||
username: The username to use for login (optional)
|
||||
password: The password to use for login (optional)
|
||||
login: Whether to login immediately or not (default: True, only works if username and password are provided)
|
||||
"""
|
||||
|
||||
# Initialize variables
|
||||
|
||||
self.cookies: Dict[str, str] = {}
|
||||
self.username: Optional[str] = None
|
||||
self.password: Optional[str] = None
|
||||
self.login_data: Optional[dict] = None
|
||||
self.login_settings: Optional[dict] = None
|
||||
self.login_params: Optional[dict] = None
|
||||
|
||||
# Login if credentials are provided and login is enabled
|
||||
|
||||
if username and password:
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
if login:
|
||||
self.login()
|
||||
|
||||
def update_cookies(self, response: HTTPResponse):
|
||||
""" Update the cookies from the given response.
|
||||
|
||||
Parameters:
|
||||
response: The HTTPResponse object to update the cookies from
|
||||
"""
|
||||
|
||||
# Get the Set-Cookie header and parse it
|
||||
|
||||
set_cookie_header = response.getheader("Set-Cookie")
|
||||
if set_cookie_header:
|
||||
cookies = SimpleCookie()
|
||||
|
||||
# Split the header by comma and load the individual cookies
|
||||
|
||||
for cookie in set_cookie_header.split(","):
|
||||
cookies.load(cookie.strip())
|
||||
|
||||
# Update the cookies
|
||||
|
||||
for value in cookies.values():
|
||||
self.cookies[value.key] = value.value
|
||||
|
||||
def request(self, *args, **kwargs) -> HTTPRequest:
|
||||
""" Create a new HTTPRequest with the current cookies.
|
||||
|
||||
Returns:
|
||||
The new HTTPRequest
|
||||
"""
|
||||
|
||||
req = HTTPRequest(*args, **kwargs)
|
||||
req.cookies = self.cookies
|
||||
return req
|
||||
|
||||
def login(self) -> bool:
|
||||
""" Login to the Austrian Post website.
|
||||
|
||||
As there are no public APIs for the Austrian Post website, this method
|
||||
basically just emulates a browser login.
|
||||
|
||||
Returns:
|
||||
True if the login was successful, False otherwise
|
||||
"""
|
||||
|
||||
assert self.username and self.password, "Username and password must be provided"
|
||||
|
||||
# Get the home page and update the cookies
|
||||
# Not sure if this is necessary, but it doesn't hurt
|
||||
|
||||
# TODO: Fetch the login URL from the home page
|
||||
|
||||
home_req = self.request(HOME_URL)
|
||||
home_res = home_req.open()
|
||||
|
||||
self.update_cookies(home_res)
|
||||
|
||||
# Get the login page and update the cookies
|
||||
# This is a POST request for some reason
|
||||
|
||||
login_req = self.request(LOGIN_INIT_URL, method="POST")
|
||||
login_res = login_req.open()
|
||||
|
||||
self.update_cookies(login_res)
|
||||
|
||||
# The previous request redirects to the actual login page
|
||||
# Get the URL and parse the query parameters
|
||||
|
||||
login_url_parts = urllib.parse.urlsplit(login_res.geturl())
|
||||
self.login_params = login_params = urllib.parse.parse_qs(
|
||||
login_url_parts.query)
|
||||
|
||||
# Generate a code verifier and code challenge
|
||||
# The code verifier is a random string of 32 bytes
|
||||
# The code challenge is the SHA256 hash of the code verifier
|
||||
# This is used for PKCE for the token request
|
||||
|
||||
self.code_verifier = SystemRandom().getrandbits(256).to_bytes(32, "big").hex()
|
||||
code_challenge = hashlib.sha256(
|
||||
self.code_verifier.encode()).digest().hex()
|
||||
login_params["code_challenge"] = code_challenge
|
||||
|
||||
# Reconstruct the login URL
|
||||
|
||||
login_url = urllib.parse.urlunsplit((login_url_parts.scheme, login_url_parts.netloc,
|
||||
login_url_parts.path, urllib.parse.urlencode(login_params), login_url_parts.fragment))
|
||||
|
||||
# Get the settings from the login page
|
||||
|
||||
login_html = login_res.read().decode()
|
||||
|
||||
login_settings_regex = re.compile(
|
||||
r'var SETTINGS = ({.*?});', re.DOTALL)
|
||||
login_settings_match = login_settings_regex.search(login_html)
|
||||
self.login_settings = login_settings = json.loads(
|
||||
login_settings_match.group(1))
|
||||
|
||||
# Get required parameters from the settings
|
||||
|
||||
transaction_id = login_settings["transId"]
|
||||
policy = login_settings["hosts"]["policy"]
|
||||
tenant = login_settings["hosts"]["tenant"]
|
||||
csrf = login_settings["csrf"]
|
||||
api = login_settings["api"]
|
||||
|
||||
# Prepare the parameters for the self-asserted login
|
||||
|
||||
self_asserted_params = {
|
||||
"tx": transaction_id,
|
||||
"p": policy,
|
||||
}
|
||||
|
||||
# Get the self-asserted login URL
|
||||
|
||||
self_asserted_url = LOGIN_SELF_ASSERTED % {"tenant": tenant}
|
||||
self_asserted_query = urllib.parse.urlencode(self_asserted_params)
|
||||
|
||||
# Encode the payload for the self-asserted login
|
||||
|
||||
self_asserted_payload = urllib.parse.urlencode({
|
||||
"request_type": "RESPONSE",
|
||||
"signInName": self.username,
|
||||
"password": self.password,
|
||||
}).encode()
|
||||
|
||||
self_asserted_req = self.request(
|
||||
f"{self_asserted_url}?{self_asserted_query}", data=self_asserted_payload, method="POST")
|
||||
|
||||
# Add required headers for the self-asserted login
|
||||
|
||||
self_asserted_req.add_header(
|
||||
"Content-Type", "application/x-www-form-urlencoded")
|
||||
self_asserted_req.add_header("X-CSRF-TOKEN", csrf)
|
||||
self_asserted_req.add_header("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
# Perform the self-asserted login
|
||||
|
||||
self_asserted_res = self_asserted_req.open()
|
||||
self_asserted_response = json.loads(self_asserted_res.read().decode())
|
||||
|
||||
# Check if the login was successful
|
||||
|
||||
if self_asserted_response["status"] != "200":
|
||||
return False
|
||||
|
||||
# Update the cookies
|
||||
|
||||
self.update_cookies(self_asserted_res)
|
||||
|
||||
# Prepare the parameters for the confirmation page
|
||||
|
||||
confirmation_params = {
|
||||
"rememberMe": "false",
|
||||
"csrf_token": csrf,
|
||||
"tx": transaction_id,
|
||||
"p": policy,
|
||||
}
|
||||
|
||||
# Prepare the URL for the confirmation page
|
||||
|
||||
confirmation_url = LOGIN_CONFIRMED % {
|
||||
"tenant": tenant, "api": api} + f"?{urllib.parse.urlencode(confirmation_params)}"
|
||||
|
||||
# Get the confirmation page and update the cookies
|
||||
|
||||
confirmation_req = self.request(confirmation_url)
|
||||
|
||||
confirmation_res = confirmation_req.open()
|
||||
|
||||
self.update_cookies(confirmation_res)
|
||||
|
||||
# Use FormParser to parse the confirmation page
|
||||
|
||||
confirmation_html = confirmation_res.read().decode()
|
||||
|
||||
confirmation_parser = FormParser()
|
||||
confirmation_parser.feed(confirmation_html)
|
||||
|
||||
# Get the form data from the confirmation page
|
||||
|
||||
form_action = confirmation_parser.form_action
|
||||
form_data = confirmation_parser.form_fields
|
||||
|
||||
# Check if the server returned an error
|
||||
|
||||
if "error" in form_data.keys():
|
||||
print(form_data["error_description"])
|
||||
return False
|
||||
|
||||
# Prepare the final request
|
||||
|
||||
final_url = form_action
|
||||
final_payload = urllib.parse.urlencode(form_data).encode()
|
||||
|
||||
final_req = self.request(final_url, data=final_payload, method="POST")
|
||||
|
||||
# Add required headers for the final request
|
||||
|
||||
final_req.add_header(
|
||||
"Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
# Perform the final request and update the cookies
|
||||
|
||||
final_res = final_req.open()
|
||||
self.update_cookies(final_res)
|
||||
|
||||
# Store the login data
|
||||
|
||||
self.login_data = form_data
|
||||
|
||||
# If we got this far, the login was hopefully successful
|
||||
|
||||
return self.logged_in()
|
||||
|
||||
def logged_in(self) -> bool:
|
||||
'''Check if the user is logged in
|
||||
|
||||
TODO: Actually verify the login status instead of just checking if login_data is set
|
||||
|
||||
Returns:
|
||||
bool: True if the user is logged in, False otherwise
|
||||
'''
|
||||
return bool(self.login_data)
|
||||
|
||||
def get_token(self) -> str:
|
||||
'''Exchange the id_token for an access_token
|
||||
|
||||
Currently not working – may be replaced by more specific methods
|
||||
|
||||
Returns:
|
||||
str: The access_token
|
||||
'''
|
||||
|
||||
# Assert that the user is logged in
|
||||
|
||||
if not self.logged_in():
|
||||
raise Exception("Not authenticated.")
|
||||
|
||||
# Prepare the request
|
||||
|
||||
token_req = self.request(TOKEN, method="POST")
|
||||
token_req.add_header(
|
||||
"Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
# Prepare the payload
|
||||
|
||||
print(self.login_data)
|
||||
|
||||
token_payload = {
|
||||
# TODO: Get this from somewhere dynamically
|
||||
"client_id": "e2cfcb2a-7dc8-4110-ac2a-9647dd095d9e",
|
||||
# TODO: Get this from somewhere dynamically if required
|
||||
"redirect_uri": "https://services.post.at",
|
||||
# TODO: Get this from somewhere dynamically if required
|
||||
"scope": "openid profile offline_access",
|
||||
"code": self.login_data["code"], # TODO: Is this correct?
|
||||
"code_verifier": self.code_verifier,
|
||||
# TODO: Get this from somewhere dynamically if required
|
||||
"grant_type": "authorization_code",
|
||||
"client_info": 1, # TODO: Get this from somewhere dynamically if required
|
||||
# TODO: Get this from somewhere dynamically...
|
||||
"client-request-id": "706a9572-5395-4e05-9d80-9317491696d6",
|
||||
}
|
||||
|
||||
# Perform the request
|
||||
|
||||
token_req.data = urllib.parse.urlencode(token_payload).encode()
|
||||
token_res = token_req.open()
|
||||
|
||||
# Parse the response
|
||||
|
||||
token_response = json.loads(token_res.read().decode())
|
||||
|
||||
# For now, just print the response
|
||||
|
||||
print(token_response)
|
||||
|
||||
return "" # TODO: Actually return the access_token
|
||||
|
||||
def get_shipments_token(self):
|
||||
|
||||
if not self.logged_in():
|
||||
raise Exception("Not authenticated.")
|
||||
|
||||
# Prepare the request
|
||||
|
||||
authorize_url = AUTHORIZE % (
|
||||
self.login_settings["hosts"]["tenant"], self.login_settings["api"])
|
||||
|
||||
print(authorize_url)
|
||||
|
||||
token_req = self.request(authorize_url, method="GET")
|
||||
|
||||
# Prepare the query string
|
||||
|
||||
token_payload = {
|
||||
"response_type": "token",
|
||||
"scope": "https://login.post.at/sendungenapi-prod/Sendungen.All openid profile",
|
||||
"client_id": self.login_params["client_id"],
|
||||
"redirect_uri": self.login_params["redirect_uri"],
|
||||
"state": self.login_params["state"],
|
||||
"nonce": self.login_params["nonce"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
def get_shipments(self):
|
||||
if not self.logged_in():
|
||||
raise Exception("Not authenticated.")
|
||||
|
||||
query = {
|
||||
"query": """query
|
||||
{ sendungen: sendungen(
|
||||
tagFilter: \"Empfangen\"
|
||||
postProcessingOptions : {elementCount: 2}
|
||||
)
|
||||
{ sendungen {
|
||||
sendungsnummer
|
||||
estimatedDelivery {
|
||||
startDate
|
||||
endDate
|
||||
startTime
|
||||
endTime
|
||||
}
|
||||
sender
|
||||
status
|
||||
bezeichnung
|
||||
sendungsEvents {
|
||||
status
|
||||
timestamp
|
||||
reasontypecode
|
||||
}
|
||||
customsInformation {
|
||||
customsDocumentAvailable
|
||||
userDocumentNeeded
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
}
|
||||
|
||||
req = self.request(GRAPHQL_AUTHENTICATED,
|
||||
data=json.dumps(query).encode())
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Authorization", f"Bearer {self.id_token}")
|
||||
|
||||
res = req.open()
|
||||
return json.loads(res.read().decode())
|
||||
|
||||
def get_shipment_status_public(self, tracking_number):
|
||||
req = self.request(GRAPHQL_PUBLIC)
|
||||
|
||||
query = {
|
||||
"query": """query {
|
||||
einzelsendung(sendungsnummer: \"%s\") {
|
||||
sendungsnummer
|
||||
branchkey
|
||||
estimatedDelivery {
|
||||
startDate
|
||||
endDate
|
||||
startTime
|
||||
endTime
|
||||
}
|
||||
dimensions {
|
||||
height
|
||||
width
|
||||
length
|
||||
}
|
||||
status
|
||||
weight
|
||||
sendungsEvents {
|
||||
timestamp
|
||||
status
|
||||
reasontypecode
|
||||
text
|
||||
textEn
|
||||
eventpostalcode
|
||||
eventcountry
|
||||
}
|
||||
customsInformation {
|
||||
customsDocumentAvailable,
|
||||
userDocumentNeeded
|
||||
}
|
||||
}
|
||||
}""" % tracking_number
|
||||
}
|
||||
|
||||
req.add_json_payload(query)
|
||||
|
||||
res = req.open()
|
||||
return json.loads(res.read().decode())
|
||||
|
||||
def get_shipment_status(self, tracking_number):
|
||||
if self.logged_in():
|
||||
# TODO: Implement function using graphqlAuthenticated
|
||||
pass
|
||||
|
||||
return self.get_shipment_status_public(tracking_number)
|
14
src/postat/classes/config.py
Normal file
14
src/postat/classes/config.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from configparser import ConfigParser
|
||||
from pathlib import Path
|
||||
|
||||
class Config(ConfigParser):
|
||||
def __init__(self, path: str = "config.ini", *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.read(path)
|
||||
|
||||
def username(self):
|
||||
return self["POST"].get("Username")
|
||||
|
||||
def password(self):
|
||||
return self["POST"].get("Password")
|
30
src/postat/classes/http.py
Normal file
30
src/postat/classes/http.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from urllib.request import Request, urlopen
|
||||
|
||||
import json
|
||||
|
||||
USER_AGENT = "PostAT/dev (https://kumig.it/kumitterer/postat.git)"
|
||||
|
||||
class HTTPRequest(Request):
|
||||
def build_cookie_header(self):
|
||||
cookiestrings = [f"{name}={value}" for name, value in self.cookies.items()]
|
||||
return "; ".join(cookiestrings)
|
||||
|
||||
def open(self):
|
||||
if self.cookies:
|
||||
self.add_header("Cookie", self.build_cookie_header())
|
||||
|
||||
return urlopen(self)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.cookies = dict()
|
||||
self.add_header("User-Agent", USER_AGENT)
|
||||
|
||||
def add_json_payload(self, payload: dict|str):
|
||||
self.add_header("Content-Type", "application/json")
|
||||
|
||||
if isinstance(payload, dict):
|
||||
payload = json.dumps(payload)
|
||||
|
||||
self.data = payload.encode("utf-8")
|
58
src/postat/classes/log.py
Normal file
58
src/postat/classes/log.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
import logging
|
||||
import datetime
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
class LogLevel(Enum):
|
||||
DEBUG = logging.DEBUG
|
||||
INFO = logging.INFO
|
||||
WARNING = logging.WARNING
|
||||
ERROR = logging.ERROR
|
||||
CRITICAL = logging.CRITICAL
|
||||
|
||||
class Logger:
|
||||
def __init__(self, name: Optional[str] = None, log_file: Optional[str] = None, level: LogLevel = LogLevel.WARNING):
|
||||
self.logger = logging.getLogger(name or __name__)
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
if log_file:
|
||||
fh = logging.FileHandler(log_file)
|
||||
fh.setFormatter(formatter)
|
||||
self.logger.addHandler(fh)
|
||||
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(formatter)
|
||||
self.logger.addHandler(ch)
|
||||
|
||||
self.set_log_level(level)
|
||||
|
||||
def set_log_level(self, level: LogLevel):
|
||||
self.logger.setLevel(level.value)
|
||||
|
||||
def log_message(self, message: str, level: LogLevel = LogLevel.INFO):
|
||||
if level == LogLevel.DEBUG:
|
||||
self.logger.debug(message)
|
||||
elif level == LogLevel.INFO:
|
||||
self.logger.info(message)
|
||||
elif level == LogLevel.WARNING:
|
||||
self.logger.warning(message)
|
||||
elif level == LogLevel.ERROR:
|
||||
self.logger.error(message)
|
||||
elif level == LogLevel.CRITICAL:
|
||||
self.logger.critical(message)
|
||||
|
||||
def log_debug(self, message: str):
|
||||
self.log_message(message, LogLevel.DEBUG)
|
||||
|
||||
def log_info(self, message: str):
|
||||
self.log_message(message, LogLevel.INFO)
|
||||
|
||||
def log_warning(self, message: str):
|
||||
self.log_message(message, LogLevel.WARNING)
|
||||
|
||||
def log_error(self, message: str):
|
||||
self.log_message(message, LogLevel.ERROR)
|
||||
|
||||
def log_critical(self, message: str):
|
||||
self.log_message(message, LogLevel.CRITICAL)
|
23
src/postat/classes/parser.py
Normal file
23
src/postat/classes/parser.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from html.parser import HTMLParser
|
||||
|
||||
class FormParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.form_fields = {}
|
||||
self.form_action = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'form':
|
||||
for name, value in attrs:
|
||||
if name == 'action':
|
||||
self.form_action = value
|
||||
elif tag == 'input':
|
||||
field_name = None
|
||||
field_value = None
|
||||
for name, value in attrs:
|
||||
if name == 'name':
|
||||
field_name = value
|
||||
elif name == 'value':
|
||||
field_value = value
|
||||
if field_name:
|
||||
self.form_fields[field_name] = field_value
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
26
tests/authenticated.py
Normal file
26
tests/authenticated.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from postat.classes.api import PostAPI
|
||||
from postat.classes.config import Config
|
||||
|
||||
import unittest
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
def test_config(self):
|
||||
config = Config()
|
||||
config.read("config.ini")
|
||||
self.assertTrue(config.username())
|
||||
self.assertTrue(config.password())
|
||||
|
||||
class TestAPI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = Config()
|
||||
self.config.read("config.ini")
|
||||
self.api = PostAPI(self.config.username(), self.config.password(), False)
|
||||
|
||||
def test_login(self):
|
||||
login_status = self.api.login()
|
||||
self.assertTrue(self.api.logged_in())
|
||||
|
||||
def test_token(self):
|
||||
self.api.login()
|
||||
token = self.api.get_token()
|
||||
self.assertTrue(token)
|
19
tests/public.py
Normal file
19
tests/public.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
import unittest
|
||||
|
||||
from postat.classes.api import PostAPI
|
||||
|
||||
class TestAPI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.api = PostAPI()
|
||||
|
||||
def test_login(self):
|
||||
with self.assertRaises(AssertionError):
|
||||
login_status = self.api.login()
|
||||
|
||||
self.assertFalse(self.api.logged_in())
|
||||
|
||||
def test_shipment_status(self):
|
||||
tracking_number = "1040906121766650280101"
|
||||
shipment_status = self.api.get_shipment_status(tracking_number)
|
||||
self.assertEqual(shipment_status["data"]["einzelsendung"]["sendungsnummer"], tracking_number)
|
||||
|
Loading…
Reference in a new issue