Skip to content

Commit

Permalink
Update project. Add build workflow
Browse files Browse the repository at this point in the history
  • Loading branch information
denisbondar committed Jun 23, 2023
1 parent 1522df8 commit 5116113
Show file tree
Hide file tree
Showing 10 changed files with 178 additions and 70 deletions.
24 changes: 16 additions & 8 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
__pycache__/
.git/
.idea/
cache/
venv/
.gitignore
docker-build.sh
docker-run.sh
# Ignore everything
*

# Allow files and directories
!/main.py
!/web.py
!/requirements.txt
!/Dockerfile
!/docker

# Ignore unnecessary files inside allowed directories
# This should go after the allowed directories
**/*~
**/*.log
**/.DS_Store
**/Thumbs.db
61 changes: 61 additions & 0 deletions .github/workflows/build-docker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Docker Image Build and Push to registry

on:
workflow_dispatch:

push:
tags: [v*]

jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Python 3.11
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Python deps
run: |
pip install -r requirements.txt
pip install pytest ruff
- name: Lint with Ruff
run: ruff .
- name: Run tests
run: pytest tests

build:
needs: tests
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v3

- name: Log in to the Container registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: denisbondar/strava-heatmap-cache
tags: |
type=raw,value=latest
type=pep440,pattern={{version}}
labels: |
maintainer=Denis Bondar <[email protected]>
org.opencontainers.image.title=Strava Heatmap Cache
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
27 changes: 14 additions & 13 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
FROM python:3.8-alpine as base
FROM python:3.11-slim-bullseye as base

COPY requirements.txt /

RUN apk update \
&& apk add --no-cache --virtual \
.build-deps \
gcc \
musl-dev \
curl \
linux-headers

RUN pip install -r requirements.txt

################################################################################

FROM python:3.8-alpine

FROM python:3.11-slim-bullseye

RUN mkdir -p /app/cache

WORKDIR /app
COPY --from=base /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages
COPY ./*.py /app/

COPY ./docker/rootfs /
COPY --from=base /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY web.py main.py ./

RUN chmod +x /usr/bin/docker-entrypoint.sh

VOLUME /app/cache

VOLUME /var/run/strava.sock

EXPOSE 8080

ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"]

CMD ["python3", "web.py"]
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ mkdir -p /var/strava-cache
области географических координат и в заданном диапазоне масштабов. Если тайл
отсутствует — он загружается с CDN-серверов Strava и сохраняется в кэше.

Для запуска построителя кэша достаточно выполнить команду `python main.py`
Для запуска построителя кэша достаточно выполнить команду `build`
в Docker-контейнере следующим образом:

```bash
Expand All @@ -32,7 +32,7 @@ docker run --rm \
--env POLICY=CloudFront-Policy_from_cookies \
--name strava-heatmap-cache \
denisbondar/strava-heatmap-cache \
python main.py
build
```

После того как построитель кэша отработает — работа контейнера будет завершена.
Expand All @@ -46,10 +46,14 @@ docker run --rm \

В качестве значений для переменных окружения `KEY_PAIR_ID`, `SIGNATURE`, `POLICY`
необходимо указать соответствующие значения, полученные из cookie на сайте
https://www.strava.com/heatmap. Для этого аутентифицируйтесь на сайте а затем
https://www.strava.com/heatmap. Для этого аутентифицируйтесь на сайте, затем
найдите в cookie домена strava.com переменные
`CloudFront-Key-Pair-Id`, `CloudFront-Policy`, `CloudFront-Signature`

Открыть окно с Cookie в Chrome последних версий можно перейдя в "Инструменты разработчика",
затем на вкладку "Приложение" и там в разделе "Память" открыть Файлы Cookie.
Затем в окне по центру, где появится список cookies, в поле фильтра написать **CloudFront**.

Если у вас нет учётной записи на strava.com, то вы сможете загрузить только тайлы
масштаба не более 11.

Expand Down
14 changes: 0 additions & 14 deletions docker-build.sh

This file was deleted.

21 changes: 21 additions & 0 deletions docker/rootfs/usr/bin/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -Eeo pipefail

_is_sourced() {
# https://unix.stackexchange.com/a/215279
[ "${#FUNCNAME[@]}" -ge 2 ] &&
[ "${FUNCNAME[0]}" = '_is_sourced' ] &&
[ "${FUNCNAME[1]}" = 'source' ]
}

_main() {
if [ "$1" = 'build' ]; then
exec python /app/main.py
fi

exec "$@"
}

if ! _is_sourced; then
_main "$@"
fi
60 changes: 28 additions & 32 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import asyncio
import logging
import math
import os
from collections import namedtuple
from random import choice
from time import time
from typing import List

import aiofiles
import aiohttp
from aiohttp import client_exceptions

logging.basicConfig(
format="%(asctime)s | %(levelname)-8s | %(message)s",
level=logging.INFO
)
logger = logging.getLogger(__name__)

CloudFrontAuth = namedtuple("CloudFrontAuth", "key_pair_id signature policy")
GeoPoint = namedtuple("GeoPoint", "latitude longitude")
script_abs_dir = os.path.abspath(os.path.dirname(__file__))
Expand All @@ -19,10 +25,13 @@
os.getenv('SIGNATURE'),
os.getenv('POLICY'))

EMPTY_TILE = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x00\x00\x00\x01\x00\x01\x03\x00\x00\x00f\xbc:%\x00\x00" \
b"\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\x1fIDATh\xde\xed\xc1" \
b"\x01\r\x00\x00\x00\xc2 \xfb\xa76\xc77`\x00\x00\x00\x00\x00\x00\x00\x00q\x07!\x00\x00\x01\xa7W)\xd7\x00" \
b"\x00\x00\x00IEND\xaeB`\x82"
point_1_x, point_1_y = os.getenv('AREA_APEX', '46.90946, 30.19284').split(',')
point_2_x, point_2_y = os.getenv('AREA_VERTEX', '46.10655, 31.39070').split(',')

EMPTY_TILE = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x00\x00\x00\x01\x00\x01\x03\x00\x00\x00f\xbc:%" \
b"\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\x1f" \
b"IDATh\xde\xed\xc1\x01\r\x00\x00\x00\xc2 \xfb\xa76\xc77`\x00\x00\x00\x00\x00\x00\x00\x00q\x07!\x00" \
b"\x00\x01\xa7W)\xd7\x00\x00\x00\x00IEND\xaeB`\x82"


class Tile:
Expand All @@ -35,21 +44,11 @@ def __init__(self, x: int, y: int, z: int) -> None:
def create_from_geo_coordinates(cls,
point: GeoPoint,
zoom: int):
"""
>>> Tile.create_from_geo_coordinates(GeoPoint(46.90946, 30.19284), 9)
Tile(298, 180, 9)
"""
x, y = cls.geo_to_tile(point, zoom)
return cls(x, y, zoom)

@staticmethod
def geo_to_tile(point: GeoPoint, zoom):
"""
>>> Tile.geo_to_tile(GeoPoint(46.90946, 30.19284), 9)
(298, 180)
>>> Tile.geo_to_tile(GeoPoint(46.10655, 31.39070), 9)
(300, 181)
"""
lat_rad = math.radians(point.latitude)
n = 2.0 ** zoom
x = int((point.longitude + 180.0) / 360.0 * n)
Expand All @@ -61,11 +60,6 @@ def generate_from_area(cls,
geo_coordinates_1: GeoPoint,
geo_coordinates_2: GeoPoint,
zoom_range):
"""
>>> gen = Tile.generate_from_area(GeoPoint(46.90946, 30.19284), GeoPoint(46.10655, 31.39070), range(9, 10))
>>> [t for t in gen]
[Tile(298, 180, 9), Tile(299, 180, 9), Tile(300, 180, 9), Tile(298, 181, 9), Tile(299, 181, 9), Tile(300, 181, 9)]
"""
apex = GeoPoint(max(geo_coordinates_1.latitude, geo_coordinates_2.latitude),
min(geo_coordinates_1.longitude, geo_coordinates_2.longitude))
vertex = GeoPoint(min(geo_coordinates_1.latitude, geo_coordinates_2.latitude),
Expand All @@ -81,6 +75,9 @@ def generate_from_area(cls,
def __repr__(self) -> str:
return f"Tile({self.x}, {self.y}, {self.z})"

def __eq__(self, other):
return self.x == other.x and self.y == other.y and self.z == other.z


def filename_for_file(tile: Tile) -> str:
return f"{tile.z}/{tile.x}/{tile.y}png.tile"
Expand Down Expand Up @@ -127,7 +124,7 @@ def __init__(self,
self.activity = activity
self.color = color

def fetch(self, tiles: List[Tile]):
def fetch(self, tiles: list[Tile]):
asyncio.run(self.task_queue(tiles))

def __url(self, tile) -> str:
Expand All @@ -153,7 +150,7 @@ def __url_params(self, tile: Tile):
def __tile_is_free(self, tile: Tile):
return tile.z <= self.free_tile_max_zoom

async def task_queue(self, tiles: List[Tile]):
async def task_queue(self, tiles: list[Tile]):
tasks = []
for tile in tiles:
tasks.append(asyncio.create_task(self.download_tile(tile)))
Expand All @@ -173,9 +170,9 @@ async def download_tile(self, tile: Tile):
elif response.status == 404:
await self.cache.write(tile, EMPTY_TILE)
else:
print("For %r received an unexpected status code %d: %s" % (tile,
response.status,
await response.text()))
logger.warning(
f"For {tile} received an unexpected status code {response.status}: {await response.text()}"
)
except client_exceptions.ServerDisconnectedError as e:
raise PermissionError("It is necessary to lower the value of the semaphore. %s" % e) from e
except client_exceptions.ClientOSError as e:
Expand All @@ -199,24 +196,23 @@ def warm_up(self,
if max_tiles and len(tiles) >= max_tiles:
break
if not tiles:
print("There are no tiles to load")
logger.info("There are no tiles to load")
else:
self.strava_fetcher.fetch(tiles)


if __name__ == '__main__':
# point_1 = (GeoPoint(float(x), float(y)) for x, y in os.getenv('AREA_APEX').split(','))
# point_2 = (GeoPoint(float(x), float(y)) for x, y in os.getenv('AREA_VERTEX').split(','))
cache = Cache(cache_dir)
strava_fetcher = StravaFetcher(auth_data, cache)
warmer = CacheWarmer(cache, strava_fetcher)

start_time = time()
logger.info("Start building cache.")
try:
warmer.warm_up(GeoPoint(46.90946, 30.19284),
GeoPoint(46.10655, 31.39070),
warmer.warm_up(GeoPoint(float(point_1_x.strip()), float(point_1_y.strip())),
GeoPoint(float(point_2_x.strip()), float(point_2_y.strip())),
range(7, 17),
max_tiles=8000)
except PermissionError as e:
print("Error: %s" % e)
print("Spent in", round((time() - start_time), 2), "seconds.")
logger.error(e)
logger.info(f"Spent in {round((time() - start_time), 2)} seconds.")
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "Strave Headmap Chache"
requires-python = ">=3.11"

[tool.ruff]
target-version = "py311"
line-length = 119
select = [
"E", # pycodestyle
"F", # pyflakes
"UP", # pyupgrade
]
Empty file added tests/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions tests/test_tile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from main import Tile, GeoPoint


def test_create_from_geo_coordinates():
assert Tile.create_from_geo_coordinates(GeoPoint(46.90946, 30.19284), 9) == Tile(298, 180, 9)


def test_geo_to_tile():
assert Tile.geo_to_tile(GeoPoint(46.90946, 30.19284), 9) == (298, 180)
assert Tile.geo_to_tile(GeoPoint(46.10655, 31.39070), 9) == (300, 181)


def test_generate_from_area():
gen = Tile.generate_from_area(GeoPoint(46.90946, 30.19284),
GeoPoint(46.10655, 31.39070),
range(9, 10))
assert [t for t in gen] == [Tile(298, 180, 9), Tile(299, 180, 9),
Tile(300, 180, 9), Tile(298, 181, 9),
Tile(299, 181, 9), Tile(300, 181, 9)]

0 comments on commit 5116113

Please sign in to comment.