1
0

Compare commits

..

No commits in common. "66e3d5e8a70fb4e585641f38d4865433626f9537" and "9ed6925e1eec72f82b80cae53cc54c41fbb201f6" have entirely different histories.

11 changed files with 127 additions and 39 deletions

View File

@ -1,7 +1,3 @@
# WARNING!
# THIS IS AN AUTOGENERATED FILE!
# MANUAL CHANGES CAN AND WILL BE OVERWRITTEN!
name: Build Python Container name: Build Python Container
on: on:
push: push:

View File

@ -1,7 +1,3 @@
# WARNING!
# THIS IS AN AUTOGENERATED FILE!
# MANUAL CHANGES CAN AND WILL BE OVERWRITTEN!
name: Package Python name: Package Python
on: on:
push: push:

View File

@ -1,7 +1,3 @@
# WARNING!
# THIS IS AN AUTOGENERATED FILE!
# MANUAL CHANGES CAN AND WILL BE OVERWRITTEN!
name: Run Python tests (through Pytest) name: Run Python tests (through Pytest)
on: on:

View File

@ -1,7 +1,3 @@
# WARNING!
# THIS IS AN AUTOGENERATED FILE!
# MANUAL CHANGES CAN AND WILL BE OVERWRITTEN!
name: Verify Python project can be installed, loaded and have version checked name: Verify Python project can be installed, loaded and have version checked
on: on:

View File

@ -1,7 +1,3 @@
<!-- WARNING! -->
<!-- THIS IS AN AUTOGENERATED FILE! -->
<!-- MANUAL CHANGES CAN AND WILL BE OVERWRITTEN! -->
# Conventions # Conventions
When contributing code to this project, you MUST follow the requirements When contributing code to this project, you MUST follow the requirements

View File

@ -1,6 +1,8 @@
<!-- WARNING! --> <!--- WARNING --->
<!-- THIS IS AN AUTOGENERATED FILE! --> <!--- THIS IS AN AUTO-GENERATED FILE --->
<!-- MANUAL CHANGES CAN AND WILL BE OVERWRITTEN! --> <!--- MANUAL CHANGES CAN AND WILL BE OVERWRITTEN --->
# Package-tracking # Package-tracking
@ -34,10 +36,12 @@ Full list of requirements:
- [requests](https://pypi.org/project/requests/) - [requests](https://pypi.org/project/requests/)
- [secret_loader](https://gitfub.space/Jmaa/secret_loader) - [secret_loader](https://gitfub.space/Jmaa/secret_loader)
## Contributing ## Contributing
Feel free to submit pull requests. Please follow the [Code Conventions](CONVENTIONS.md) when doing so. Feel free to submit pull requests. Please follow the [Code Conventions](CONVENTIONS.md) when doing so.
### Testing ### Testing
Testing requires the [pytest](https://docs.pytest.org/en/stable/) library. Testing requires the [pytest](https://docs.pytest.org/en/stable/) library.
@ -54,6 +58,7 @@ Test coverage can be run using the [`pytest-cov`](https://pypi.org/project/pytes
pytest --cov=package_tracking test pytest --cov=package_tracking test
``` ```
## License ## License
``` ```

View File

@ -1,8 +1,6 @@
import logging import logging
import requests from . import http, parcelsapp, secrets
from . import http, secrets
from clients.parcelsapp import ParcelsAppClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -11,9 +9,8 @@ def main():
logging.basicConfig() logging.basicConfig()
logger.setLevel('INFO') logger.setLevel('INFO')
parcelsapp_client: ParcelsAppClient = ParcelsAppClient( parcelsapp_client: parcelsapp.ParcelsAppClient = parcelsapp.ParcelsAppClient(
requests.Session(), secrets.PARCELS_API_KEY,
parcels_api_key=secrets.PARCELS_API_KEY,
) )
http.initialize_server(parcelsapp_client) http.initialize_server(parcelsapp_client)

View File

@ -1,11 +1,9 @@
import bottle import bottle
import datetime import datetime
from . import database from . import database, parcelsapp
from clients.parcelsapp import ParcelsAppClient
from clients.common import ApiError
PARCELSAPP_CLIENT: ParcelsAppClient | None = None PARCELSAPP_CLIENT: parcelsapp.ParcelsAppClient | None = None
TEMPLATE = """ TEMPLATE = """
<doctype HTML> <doctype HTML>
@ -151,7 +149,7 @@ def get_packages_from_parcels():
try: try:
tracking_results = list(PARCELSAPP_CLIENT.get_tracking_status(tracking_numbers)) tracking_results = list(PARCELSAPP_CLIENT.get_tracking_status(tracking_numbers))
error_message = None error_message = None
except ApiError as ex: except parcelsapp.ParcelsApiError as ex:
tracking_results = [] tracking_results = []
error_message = str(ex) error_message = str(ex)
@ -162,7 +160,7 @@ def get_packages_from_parcels():
return [(e, tracking_results_by_id.get(e.number)) for e in tracking_entries] return [(e, tracking_results_by_id.get(e.number)) for e in tracking_entries]
def render_tracking(error_message: str | None = None, with_form: bool = False): def render_tracking(tracking_results_with_name, error_message: str | None, with_form: bool):
tracking_results_with_name = get_packages_from_parcels() tracking_results_with_name = get_packages_from_parcels()
tracking_results_with_name.sort(key=lambda x: x[1].latest_state().date if x[1] else TODAY, reverse=True) tracking_results_with_name.sort(key=lambda x: x[1].latest_state().date if x[1] else TODAY, reverse=True)
return bottle.template(TEMPLATE, error_message=error_message, tracking_results=tracking_results_with_name, with_form=with_form) return bottle.template(TEMPLATE, error_message=error_message, tracking_results=tracking_results_with_name, with_form=with_form)
@ -187,7 +185,7 @@ def add_tracking_number():
return bottle.redirect('/') return bottle.redirect('/')
def initialize_server(parcelsapp_client: ParcelsAppClient): def initialize_server(parcelsapp_client: parcelsapp.ParcelsAppClient):
global PARCELSAPP_CLIENT global PARCELSAPP_CLIENT
PARCELSAPP_CLIENT = parcelsapp_client PARCELSAPP_CLIENT = parcelsapp_client
bottle.run(host='0.0.0.0', port=8080, debug=False) bottle.run(host='0.0.0.0', port=8080, debug=False)

View File

@ -0,0 +1,101 @@
import dataclasses
import datetime
import logging
import time
from collections.abc import Iterator
import requests
logger = logging.getLogger(__name__)
URL_TRACKING = 'https://parcelsapp.com/api/v3/shipments/tracking'
target_country = 'Denmark'
TRACKING_STATUS_CHECKING_INTERVAL = 1
@dataclasses.dataclass(frozen=True)
class ParcelState:
date: datetime.datetime
status: str
carrier: str | None
@dataclasses.dataclass(frozen=True)
class ParcelInfo:
tracking_number: str
tracking_url: str
status: str
destination: str | None
origin: str | None
states: list[ParcelState]
# TODO: More fields
def latest_state(self) -> ParcelState:
return max(self.states, key=lambda state: state.date)
class ParcelsApiError(RuntimeError):
pass
class ParcelsAppClient:
def __init__(self, api_key: str):
assert api_key is not None, 'Missing API Key'
self.api_key = api_key
def _request_json(self, method: str, url: str, **kwargs) -> dict:
request_json_data = {'apiKey': self.api_key, 'language': 'en', **kwargs}
response = requests.request(
method=method, url=url, json=request_json_data,
)
response.raise_for_status()
json_data = response.json()
if 'error' in json_data:
msg = 'Error from endpoint: {}'.format(json_data['error'])
raise ParcelsApiError(msg)
return json_data
def check_tracking_status(self, uuid: str) -> dict:
"""Function to check tracking status with UUID."""
json_data = self._request_json('GET', URL_TRACKING, uuid=uuid)
if json_data['done']:
logger.info('Tracking complete')
return json_data
else:
logger.info('Tracking in progress...')
time.sleep(TRACKING_STATUS_CHECKING_INTERVAL)
return self.check_tracking_status(uuid)
def _get_tracking_status_to_json(self, tracking_ids: list[str]) -> dict:
shipments = [
{'trackingId': tracking_id, 'country': target_country}
for tracking_id in tracking_ids
]
# Initiate tracking request
json_data = self._request_json('POST', URL_TRACKING, shipments=shipments)
if json_data.get('done'):
return json_data
return self.check_tracking_status(json_data['uuid'])
def get_tracking_status(self, tracking_ids: list[str]) -> Iterator[ParcelInfo]:
if len(tracking_ids) == 0:
return
for parcel_json in self._get_tracking_status_to_json(tracking_ids)['shipments']:
yield ParcelInfo(
tracking_number=parcel_json['trackingId'],
tracking_url=parcel_json['externalTracking'][0]['url'],
status=parcel_json['status'],
destination=parcel_json.get('destination'),
origin=parcel_json.get('origin'),
states=[
ParcelState(
status=s['status'],
date=datetime.datetime.fromisoformat(s['date']),
carrier=s['carrier'],
)
for s in parcel_json['states']
],
)

View File

@ -1,6 +1,8 @@
# WARNING! # WARNING
# THIS IS AN AUTOGENERATED FILE! #
# MANUAL CHANGES CAN AND WILL BE OVERWRITTEN! # THIS IS AN AUTOGENERATED FILE.
#
# MANUAL CHANGES CAN AND WILL BE OVERWRITTEN.
import re import re
@ -28,6 +30,7 @@ Once run, it exposes the following endpoints:
PACKAGE_DESCRIPTION_SHORT = """ PACKAGE_DESCRIPTION_SHORT = """
Small alternative frontend for tracking packages and parcels.""".strip() Small alternative frontend for tracking packages and parcels.""".strip()
def parse_version_file(text: str) -> str: def parse_version_file(text: str) -> str:
match = re.match(r'^__version__\s*=\s*(["\'])([\d\.]+)\1$', text) match = re.match(r'^__version__\s*=\s*(["\'])([\d\.]+)\1$', text)
if match is None: if match is None:
@ -35,9 +38,11 @@ def parse_version_file(text: str) -> str:
raise Exception(msg) raise Exception(msg)
return match.group(2) return match.group(2)
with open(PACKAGE_NAME + '/_version.py') as f: with open(PACKAGE_NAME + '/_version.py') as f:
version = parse_version_file(f.read()) version = parse_version_file(f.read())
REQUIREMENTS_MAIN = [ REQUIREMENTS_MAIN = [
'bottle', 'bottle',
'requests', 'requests',
@ -45,6 +50,7 @@ REQUIREMENTS_MAIN = [
] ]
REQUIREMENTS_TEST = [] REQUIREMENTS_TEST = []
setup( setup(
name=PACKAGE_NAME, name=PACKAGE_NAME,
version=version, version=version,

View File

@ -3,3 +3,4 @@ def test_import_modules():
import package_tracking # noqa import package_tracking # noqa
import package_tracking.database # noqa import package_tracking.database # noqa
import package_tracking.http # noqa import package_tracking.http # noqa
import package_tracking.parcelsapp # noqa