Compare commits

...

27 commits
v0.3 ... main

Author SHA1 Message Date
5e52dffc27
feat: migrate from woodpecker to forgejo actions
All checks were successful
/ test (push) Successful in 56s
/ codestyle (push) Successful in 55s
/ build_wheel (push) Successful in 1m51s
/ build_debian (push) Successful in 2m23s
2023-12-19 05:25:00 +01:00
5f8653797f
chore: bump version number
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-08-12 14:09:06 +02:00
fd85f5b1c2
chore: migrate from gitlab-ci to woodpecker
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-12 14:05:06 +02:00
s3lph
c94003cba7 Works, restricting to tags only 2022-07-19 23:33:03 +02:00
s3lph
69315c80e8 Add CI task to trigger a build of the repository pipeline 2022-07-19 23:29:07 +02:00
s3lph
879bd5b1b7 0.6.1 2022-07-19 03:39:46 +02:00
s3lph
675e391ea5 release.py: Use new Gitlab releases API 2022-07-19 03:02:36 +02:00
s3lph
dc3f7101f0 Fix: A specific API field has to be a string rather than float, and recent Grafana versions validate this. 2022-07-19 03:01:46 +02:00
s3lph
396e0e5c5f Fix release script 2022-02-19 03:48:26 +01:00
s3lph
90b80ae529 v0.5: Exponential backoff for retrys 2022-02-19 03:29:27 +01:00
s3lph
28c2b1d425 Bump version to 0.4.1 2021-05-24 05:34:52 +02:00
s3lph
f99496c497 0.4.1 2021-05-24 05:33:32 +02:00
s3lph
fae85de8ca Fix codestyle 2020-11-06 03:35:22 +01:00
s3lph
395393345e Implement todo exporting 2020-11-06 03:30:47 +01:00
s3lph
7f36e5b8dc Merge branch 'hotfix-release-script' into 'master'
release.py: Replace default user agent by that of curl

See merge request s3lph/icalendar-timeseries-server!9
2020-06-21 03:07:58 +00:00
s3lph
b5304cb8d7 release.py: Replace default user agent by that of curl 2020-06-21 05:01:55 +02:00
s3lph
59ea26514c Merge branch 'dev' into 'master'
Release 0.3.3

See merge request s3lph/icalendar-timeseries-server!8
2020-06-18 22:42:06 +00:00
s3lph
7963dc7bb8 Prepare 0.3.3 release 2020-06-19 00:38:50 +02:00
s3lph
58145fe178 Fix type confusion bug in recurring events 2020-06-19 00:35:12 +02:00
s3lph
7dad5ff034 Merge branch 'remove-pytz' into 'dev'
Remove pytz dependency in favor of dateutil.tz

See merge request s3lph/icalendar-timeseries-server!7
2019-11-18 20:50:04 +00:00
s3lph
e29ef0ff2c Remove pytz dependency in favor of dateutil.tz 2019-11-18 21:46:41 +01:00
s3lph
afd4710c44 Debian: Add postinst restart hook 2019-11-18 21:37:20 +01:00
s3lph
9907436a24 Merge branch 'dev' into 'master'
Fix Debian build process

See merge request s3lph/icalendar-timeseries-server!6
2019-09-24 14:27:30 +00:00
s3lph
293b30ba59 Bump version number to 0.3.2 2019-09-24 16:25:18 +02:00
s3lph
eaa3a31947 Fix debian package build process: Version number and changelog 2019-09-24 16:23:35 +02:00
s3lph
3867e177cb Merge branch 'dev' into 'master'
v0.3.1 Version number bump, because Gitlab releases are weird

See merge request s3lph/icalendar-timeseries-server!5
2019-09-21 13:11:54 +00:00
s3lph
3618f86bd1 v0.3.1 Version number bump, because Gitlab releases are weird 2019-09-21 15:10:40 +02:00
19 changed files with 374 additions and 305 deletions

View file

@ -0,0 +1,38 @@
---
on:
push:
tags:
- "v*"
jobs:
build_wheel:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Build Python wheel
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 setup.py egg_info bdist_wheel
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
build_debian:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v5
with:
python_module: icalendar_timeseries_server
package_name: icalendar-timeseries-server
package_root: package/debian/icalendar-timeseries-server
package_output_path: package/debian
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
deb: "package/debian/*.deb"

View file

@ -0,0 +1,27 @@
---
on: push
jobs:
test:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: test
run: |
apt update; apt install --yes python3-pip
pip3 install --break-system-packages -e .[test]
python3 -m coverage run --rcfile=setup.cfg -m unittest discover icalendar_timeseries_server
python3 -m coverage combine
python3 -m coverage report --rcfile=setup.cfg
codestyle:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: codestyle
run: |
apt update; apt install --yes python3-pip
pip3 install --break-system-packages -e .[test]
pycodestyle icalendar_timeseries_server

View file

@ -1,91 +0,0 @@
---
image: s3lph/icalendar-timeseries-server-ci:20190820-01
stages:
- test
- build
- release
before_script:
- export ITS_VERSION=$(python -c 'import icalendar_timeseries_server; print(icalendar_timeseries_server.__version__)')
test:
stage: test
script:
- pip3 install -e .
- sudo -u its python3 -m coverage run --rcfile=setup.cfg -m unittest discover icalendar_timeseries_server
- sudo -u its python3 -m coverage combine
- sudo -u its python3 -m coverage report --rcfile=setup.cfg
codestyle:
stage: test
script:
- pip3 install -e .
- sudo -u its pycodestyle icalendar_timeseries_server
build_wheel:
stage: build
script:
- pip3 install -e .
- python3 setup.py egg_info bdist_wheel
- cd dist
- sha256sum *.whl > SHA256SUMS
artifacts:
paths:
- "dist/*.whl"
- dist/SHA256SUMS
only:
- tags
build_debian:
stage: build
script:
# The Python package name provided by the python3-magic Debian package is "python-magic" rather than "file-magic".
- sed -re 's/file-magic/python-magic/' -i setup.py
- echo -n > package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
- |
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "icalendar-timeseries-server (${version}-1); urgency=medium\n" >> package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
done
- gzip -9n package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
- python3.7 setup.py egg_info install --root=package/debian/icalendar-timeseries-server/ --prefix=/usr --optimize=1
- cd package/debian
- mkdir -p icalendar-timeseries-server/usr/lib/python3/dist-packages/
- rsync -a icalendar-timeseries-server/usr/lib/python3.7/site-packages/ icalendar-timeseries-server/usr/lib/python3/dist-packages/
- rm -rf icalendar-timeseries-server/usr/lib/python3.7/
- find icalendar-timeseries-server/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
- find icalendar-timeseries-server/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
- mv icalendar-timeseries-server/usr/bin/icalendar-timeseries-server icalendar-timeseries-server/usr/lib/icalendar-timeseries-server/icalendar-timeseries-server
- rm -rf icalendar-timeseries-server/usr/bin
- sed -re 's$#!/usr/local/bin/python3.7$#!/usr/bin/python3$' -i icalendar-timeseries-server/usr/lib/icalendar-timeseries-server/icalendar-timeseries-server
- find icalendar-timeseries-server -type f -exec chmod 0644 {} \;
- find icalendar-timeseries-server -type d -exec chmod 755 {} \;
- find icalendar-timeseries-server -type f -name .gitkeep -delete
- chmod +x icalendar-timeseries-server/usr/lib/icalendar-timeseries-server/icalendar-timeseries-server icalendar-timeseries-server/DEBIAN/postinst icalendar-timeseries-server/DEBIAN/prerm icalendar-timeseries-server/DEBIAN/postrm
- dpkg-deb --build icalendar-timeseries-server
- mv icalendar-timeseries-server.deb "icalendar-timeseries-server_${ITS_VERSION}-1_all.deb"
- sudo -u nobody lintian "icalendar-timeseries-server_${ITS_VERSION}-1_all.deb"
- sha256sum *.deb > SHA256SUMS
artifacts:
paths:
- "package/debian/*.deb"
- package/debian/SHA256SUMS
only:
- tags
release:
stage: release
script:
- python package/release.py
only:
- tags

0
.woodpecker.yml Normal file
View file

View file

@ -1,6 +1,129 @@
# iCalendar Timeseries Server Changelog # iCalendar Timeseries Server Changelog
<!-- BEGIN RELEASE v0.6.3 -->
## Version 0.6.3
### Changes
<!-- BEGIN CHANGES 0.6.3 -->
- Migration from Woodpecker to Forgejo Actions.
<!-- END CHANGES 0.6.3 -->
<!-- END RELEASE v0.6.3 -->
<!-- BEGIN RELEASE v0.6.2 -->
## Version 0.6.2
### Changes
<!-- BEGIN CHANGES 0.6.2 -->
- Migration from Gitlab-CI to Woodpecker
<!-- END CHANGES 0.6.2 -->
<!-- END RELEASE v0.6.2 -->
<!-- BEGIN RELEASE v0.6.1 -->
## Version 0.6.1
### Changes
<!-- BEGIN CHANGES 0.6.1 -->
- Same fix, but for todo as well as events.
<!-- END CHANGES 0.6.1 -->
<!-- END RELEASE v0.6.1 -->
<!-- BEGIN RELEASE v0.6 -->
## Version 0.6
### Changes
<!-- BEGIN CHANGES 0.6 -->
- Fix: A specific API field has to be a string rather than float, and recent Grafana versions validate this.
<!-- END CHANGES 0.6 -->
<!-- END RELEASE v0.6 -->
<!-- BEGIN RELEASE v0.5 -->
## Version 0.5
### Changes
<!-- BEGIN CHANGES 0.5 -->
- Retry calendar scraping with exponential backoff.
<!-- END CHANGES 0.5 -->
<!-- END RELEASE v0.5 -->
<!-- BEGIN RELEASE v0.4.1 -->
## Version 0.4.1
### Changes
<!-- BEGIN CHANGES 0.4.1 -->
- Fix todo sorting by due date.
- Update README regarding `todo` time series.
<!-- END CHANGES 0.4.1 -->
<!-- END RELEASE v0.4.1 -->
<!-- BEGIN RELEASE v0.4.0 -->
## Version 0.4.0
### Changes
<!-- BEGIN CHANGES 0.4.0 -->
- VTODO components are exported in a second time series, `todo` . Todo recurrence is not supported yet though.
<!-- END CHANGES 0.4.0 -->
<!-- END RELEASE v0.4.0 -->
<!-- BEGIN RELEASE v0.3.3 -->
## Version 0.3.3
### Changes
<!-- BEGIN CHANGES 0.3.3 -->
- Fix type confusion bug in recurring events
- Remove pytz dependency in favor of dateutil.tz
<!-- END CHANGES 0.3.3 -->
<!-- END RELEASE v0.3.3 -->
<!-- BEGIN RELEASE v0.3.2 -->
## Version 0.3.2
### Changes
<!-- BEGIN CHANGES 0.3.2 -->
- Fix Debian package build process
<!-- END CHANGES 0.3.2 -->
<!-- END RELEASE v0.3.2 -->
<!-- BEGIN RELEASE v0.3.1 -->
## Version 0.3.1
### Changes
<!-- BEGIN CHANGES 0.3.1 -->
- Bump Version Number
<!-- END CHANGES 0.3.1 -->
<!-- END RELEASE v0.3.1 -->
<!-- BEGIN RELEASE v0.3 --> <!-- BEGIN RELEASE v0.3 -->
## Version 0.3 ## Version 0.3

View file

@ -4,8 +4,8 @@ This project is a small service that scrapes iCalendar files served
over HTTP, parses their contents and returns the data in a timeseries over HTTP, parses their contents and returns the data in a timeseries
format compatible to the `/api/v1/query` API endpoint of a Prometheus format compatible to the `/api/v1/query` API endpoint of a Prometheus
server. This allows e.g. a Grafana administrator to add a Prometheus server. This allows e.g. a Grafana administrator to add a Prometheus
data source pointing at this server, returning the events in the data source pointing at this server, returning calendar events in the
calendars in the `event` metric. `event` metric and todos in the `todo` metric.
## Example ## Example
@ -78,7 +78,6 @@ The server would transform this into the following API response:
- `icalendar`: Parse iCalendar - `icalendar`: Parse iCalendar
- `isodate`: Parse ISO-8601 time periods - `isodate`: Parse ISO-8601 time periods
- `jinja2`: Template value replacements - `jinja2`: Template value replacements
- `pytz`: Work with timezones
## Configuration ## Configuration
@ -148,7 +147,7 @@ Configuration is done through a JSON config file:
| `calendars.*.auth[?type=='basic'].password` | string | The Basic Auth password to authenticate with. | | `calendars.*.auth[?type=='basic'].password` | string | The Basic Auth password to authenticate with. |
| `calendars.*.auth[?type=='tls'].keyfile` | string | Path to the key file containing the TLS private key, client certificate and certificate chain, in PEM format. | | `calendars.*.auth[?type=='tls'].keyfile` | string | Path to the key file containing the TLS private key, client certificate and certificate chain, in PEM format. |
| `calendars.*.auth[?type=='tls'].passphrase` | string | Passphrase for the private key (optional). | | `calendars.*.auth[?type=='tls'].passphrase` | string | Passphrase for the private key (optional). |
| `key_replace` | dict | Labels to rename, might be necessary e.g. for column ordering in Grafana. | | `key_replace` | dict | Labels to rename, might be necessary e.g. for column ordering in Grafana 6 and earlier. |
| `keys(key_replace)` | string | The labels to rename. | | `keys(key_replace)` | string | The labels to rename. |
| `key_replace.*` | string | The names to rename the labels to. | | `key_replace.*` | string | The names to rename the labels to. |
| `value_replace` | dict | Label values to postprocess. | | `value_replace` | dict | Label values to postprocess. |
@ -169,6 +168,12 @@ In addition, PromQL label filters can be used.
event{calendar="public",foo=~".*"} event{calendar="public",foo=~".*"}
``` ```
Alongside with events, todos are exported in a second time series:
```
todo{status!="COMPLETED"}
```
## Why Prometheus API ## Why Prometheus API
- It's JSON. A JSON generator is builtin in Python, so no further dependency. - It's JSON. A JSON generator is builtin in Python, so no further dependency.

View file

@ -1,2 +1,2 @@
__version__ = '0.3' __version__ = '0.6.3'

View file

@ -7,7 +7,7 @@ import bottle
from icalendar_timeseries_server.config import get_config from icalendar_timeseries_server.config import get_config
from icalendar_timeseries_server.event import Metric from icalendar_timeseries_server.event import Metric
from icalendar_timeseries_server.cal import get_calendar from icalendar_timeseries_server.cal import get_calendar_events, get_calendar_todos
from icalendar_timeseries_server.query import MetricQuery from icalendar_timeseries_server.query import MetricQuery
@ -31,9 +31,15 @@ def prometheus_api():
try: try:
for name in get_config().calendars.keys(): for name in get_config().calendars.keys():
events.extend(get_calendar(name)) if q.name == 'event':
events.extend(get_calendar_events(name))
events = list(filter(q, events)) events = list(filter(q, events))
events.sort(key=lambda e: e.start) events.sort(key=lambda e: e.start)
elif q.name == 'todo':
events.extend(get_calendar_todos(name))
events = list(filter(q, events))
# Sort by due date and priority
events.sort(key=lambda e: (e.due is None, e.due, e.priority))
response = { response = {
'status': 'success', 'status': 'success',
'data': { 'data': {

View file

@ -13,9 +13,11 @@ from isodate import Duration
from icalendar_timeseries_server import __version__ from icalendar_timeseries_server import __version__
from icalendar_timeseries_server.config import get_config, CalendarConfig from icalendar_timeseries_server.config import get_config, CalendarConfig
from icalendar_timeseries_server.event import Event from icalendar_timeseries_server.event import Event
from icalendar_timeseries_server.todo import Todo
_SCRAPE_CACHE: Dict[str, List[Event]] = dict() _EVENT_SCRAPE_CACHE: Dict[str, List[Event]] = dict()
_TODO_SCRAPE_CACHE: Dict[str, List[Todo]] = dict()
_SCRAPE_CACHE_LOCK: Lock = Lock() _SCRAPE_CACHE_LOCK: Lock = Lock()
__py_version: str = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}' __py_version: str = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
@ -26,6 +28,8 @@ def _parse_recurring(event: cal.Event, start: datetime, end: datetime, duration:
occurences: List[datetime] = [] occurences: List[datetime] = []
evstart = event.get('dtstart').dt evstart = event.get('dtstart').dt
if isinstance(evstart, date) and not isinstance(evstart, datetime):
evstart = datetime(evstart.year, evstart.month, evstart.day, tzinfo=start.tzinfo)
# First occurence lies in the future; no need to process further # First occurence lies in the future; no need to process further
if evstart >= end: if evstart >= end:
return occurences return occurences
@ -51,16 +55,13 @@ def _parse_recurring(event: cal.Event, start: datetime, end: datetime, duration:
def _scrape_calendar(name: str, config: CalendarConfig, start: datetime, end: datetime): def _scrape_calendar(name: str, config: CalendarConfig, start: datetime, end: datetime):
global _SCRAPE_CACHE, _SCRAPE_CACHE_LOCK global _EVENT_SCRAPE_CACHE, _TODO_SCRAPE_CACHE, _SCRAPE_CACHE_LOCK
events = [] events = []
todos = []
opener: urllib.request.OpenerDirector = config.get_url_opener() opener: urllib.request.OpenerDirector = config.get_url_opener()
try:
with opener.open(config.url) as response: with opener.open(config.url) as response:
data = response.read().decode('utf-8') data = response.read().decode('utf-8')
except BaseException:
logging.exception(f'An error occurred while scraping the calendar endpoint "{name}" ({config.url})')
return
calendar = cal.Calendar.from_ical(data) calendar = cal.Calendar.from_ical(data)
for element in calendar.walk(): for element in calendar.walk():
@ -86,34 +87,58 @@ def _scrape_calendar(name: str, config: CalendarConfig, start: datetime, end: da
for occurence in occurences: for occurence in occurences:
if start <= occurence + duration and occurence < end: if start <= occurence + duration and occurence < end:
events.append(Event(name, element, occurence, occurence + duration)) events.append(Event(name, element, occurence, occurence + duration))
elif element.name == "VTODO":
dtstart = element.get('dtstamp').dt
duration = timedelta(0)
if 'dtstart' in element:
dtstart = element.get('dtstart').dt
if 'duration' in element:
duration = element.get('duration').dt
todos.append(Todo(name, element, dtstart, dtstart + duration))
with _SCRAPE_CACHE_LOCK: with _SCRAPE_CACHE_LOCK:
_SCRAPE_CACHE[name] = events _EVENT_SCRAPE_CACHE[name] = events
_TODO_SCRAPE_CACHE[name] = todos
def scrape_calendar(name: str, config: CalendarConfig): def scrape_calendar(name: str, config: CalendarConfig, retry: int):
# Get current time in configured timezone # Get current time in configured timezone
tz = get_config().tz tz = get_config().tz
now: datetime = datetime.now(tz) now: datetime = datetime.now(tz)
# Reschedule calendar scraping # Only scrape at most once a minute
cron = Timer(config.interval.totimedelta(start=now).total_seconds(), interval = max(int(config.interval.totimedelta(start=now).total_seconds()), 60)
lambda: scrape_calendar(name, config))
cron.start()
# Compute interval for which to return events # Compute interval for which to return events
start_delta: Duration = get_config().start_delta start_delta: Duration = get_config().start_delta
end_delta: Duration = get_config().end_delta end_delta: Duration = get_config().end_delta
start: datetime = now + start_delta start: datetime = now + start_delta
end: datetime = now + end_delta end: datetime = now + end_delta
# Scrape and parse the calendar # Scrape and parse the calendar
try:
_scrape_calendar(name, config, start, end) _scrape_calendar(name, config, start, end)
# Reschedule calendar scraping
cron = Timer(interval, lambda: scrape_calendar(name, config, 0))
except BaseException:
# reschedule with exponential backoff, but no more than the regular scrape interval
backoff_seconds = min(60 * 2**retry, interval)
logging.exception(f'An error occurred while scraping the calendar endpoint "{name}" '
f'({config.url}), retrying in {backoff_seconds}s.')
cron = Timer(backoff_seconds, lambda: scrape_calendar(name, config, retry+1))
cron.start()
def start_scrape_calendar(name: str, config: CalendarConfig): def start_scrape_calendar(name: str, config: CalendarConfig):
# Schedule first calendar scraping # Schedule first calendar scraping
cron = Timer(0, lambda: scrape_calendar(name, config)) cron = Timer(0, lambda: scrape_calendar(name, config, retry=0))
cron.start() cron.start()
def get_calendar(name: str): def get_calendar_events(name: str):
global _SCRAPE_CACHE global _EVENT_SCRAPE_CACHE
with _SCRAPE_CACHE_LOCK: with _SCRAPE_CACHE_LOCK:
return _SCRAPE_CACHE.get(name, []) return _EVENT_SCRAPE_CACHE.get(name, [])
def get_calendar_todos(name: str):
global _TODO_SCRAPE_CACHE
with _SCRAPE_CACHE_LOCK:
return _TODO_SCRAPE_CACHE.get(name, [])

View file

@ -8,9 +8,10 @@ import urllib.request
import sys import sys
import logging import logging
import pytz
import jinja2 import jinja2
from isodate import Duration, parse_duration from isodate import Duration, parse_duration
from dateutil import tz
from datetime import tzinfo
from icalendar_timeseries_server import __version__ from icalendar_timeseries_server import __version__
@ -93,7 +94,7 @@ class Config:
config = dict() config = dict()
self._addr: str = _keycheck('addr', config, str, '', default_value='127.0.0.1') self._addr: str = _keycheck('addr', config, str, '', default_value='127.0.0.1')
self._port: int = _keycheck('port', config, int, '', default_value=8090) self._port: int = _keycheck('port', config, int, '', default_value=8090)
self._tz: pytz.tzinfo = _parse_timezone('tz', config, '', default_value='UTC') self._tz: tzinfo = _parse_timezone('tz', config, '', default_value='UTC')
self._start_delta: Duration = _parse_timedelta('start_delta', config, '', default_value='PT') self._start_delta: Duration = _parse_timedelta('start_delta', config, '', default_value='PT')
self._end_delta: Duration = _parse_timedelta('end_delta', config, '', default_value='P30D') self._end_delta: Duration = _parse_timedelta('end_delta', config, '', default_value='P30D')
self._calendars: Dict[str, CalendarConfig] = self._parse_calendars_config('calendars', config, '') self._calendars: Dict[str, CalendarConfig] = self._parse_calendars_config('calendars', config, '')
@ -120,7 +121,7 @@ class Config:
return self._port return self._port
@property @property
def tz(self) -> pytz.tzinfo: def tz(self) -> tzinfo:
return self._tz return self._tz
@property @property
@ -185,7 +186,10 @@ def _parse_timezone(key: str,
path: str, path: str,
default_value: Any = None) -> Any: default_value: Any = None) -> Any:
zonename: str = _keycheck(key, config, str, path, default_value=default_value) zonename: str = _keycheck(key, config, str, path, default_value=default_value)
return pytz.timezone(zonename) zone: zoneinfo = tz.gettz(zonename)
if zone is None:
raise ValueError(f'Unknown timezone: {zonename}')
return zone
def _parse_key_replace(key: str, def _parse_key_replace(key: str,

View file

@ -1,4 +1,4 @@
from typing import Any, Dict, List, Set from typing import Any, Dict, List
import icalendar import icalendar
import jinja2 import jinja2
@ -59,7 +59,7 @@ class Event(Metric):
}, },
'value': [ 'value': [
self.start.timestamp(), self.start.timestamp(),
1 "1"
] ]
} }
event['metric'].update(self._labels) event['metric'].update(self._labels)

View file

@ -172,7 +172,7 @@ class MetricQuery:
elif filterstate != 0: elif filterstate != 0:
raise ValueError('Unexpected EOF') raise ValueError('Unexpected EOF')
def __call__(self, metric: Metric): def __call__(self, metric: Metric) -> bool:
""" """
Applies the filter deducted from the query string to the given metric. Applies the filter deducted from the query string to the given metric.
@ -188,3 +188,7 @@ class MetricQuery:
return False return False
# Return True if all filters matched # Return True if all filters matched
return True return True
@property
def name(self) -> str:
return self._metric_name

View file

@ -2,9 +2,9 @@
import unittest import unittest
import json import json
import pytz from datetime import timedelta, tzinfo
from datetime import timedelta
from dateutil import tz
from isodate.duration import Duration from isodate.duration import Duration
from icalendar_timeseries_server.config import _keycheck, _parse_timedelta, _parse_timezone, Config from icalendar_timeseries_server.config import _keycheck, _parse_timedelta, _parse_timezone, Config
@ -113,10 +113,10 @@ class ConfigTest(unittest.TestCase):
'tz': 'Europe/Zurich', 'tz': 'Europe/Zurich',
'notz': 'North/Winterfell' 'notz': 'North/Winterfell'
} }
self.assertEqual(_parse_timezone('tz', config, ''), pytz.timezone('Europe/Zurich')) self.assertEqual(_parse_timezone('tz', config, ''), tz.gettz('Europe/Zurich'))
self.assertEqual(_parse_timezone('def', config, '', default_value='Europe/Berlin'), self.assertEqual(_parse_timezone('def', config, '', default_value='Europe/Berlin'),
pytz.timezone('Europe/Berlin')) tz.gettz('Europe/Berlin'))
with self.assertRaises(pytz.exceptions.UnknownTimeZoneError): with self.assertRaises(ValueError):
_parse_timezone('notz', config, '') _parse_timezone('notz', config, '')
def test_parse_full_config_valid(self): def test_parse_full_config_valid(self):
@ -125,7 +125,7 @@ class ConfigTest(unittest.TestCase):
self.assertEqual(config.port, 8090) self.assertEqual(config.port, 8090)
self.assertEqual(config.start_delta, Duration(hours=-3)) self.assertEqual(config.start_delta, Duration(hours=-3))
self.assertEqual(config.end_delta, Duration(days=30)) self.assertEqual(config.end_delta, Duration(days=30))
self.assertEqual(config.tz, pytz.timezone('Europe/Zurich')) self.assertEqual(config.tz, tz.gettz('Europe/Zurich'))
def test_parse_calendars(self): def test_parse_calendars(self):
config = Config(json.loads(_CONFIG_VALID)) config = Config(json.loads(_CONFIG_VALID))

View file

@ -0,0 +1,77 @@
from typing import Any, Dict, List
import icalendar
import jinja2
from datetime import datetime, date, timedelta
from icalendar_timeseries_server.config import get_config, get_jenv
from icalendar_timeseries_server.query import Metric
_ATTRIBUTES: List[str] = [
'class',
'description',
'geo',
'location',
'organizer',
'percent-complete',
'priority',
'status',
'summary',
'url',
'attach'
]
class Todo(Metric):
def __init__(self, cname: str, todo: icalendar.cal.Todo, start: datetime, end: datetime):
self.calendar: str = cname
self.start = start
due = todo.get('due', None)
if due:
if isinstance(due.dt, datetime):
self.due = due.dt
elif isinstance(due.dt, date):
self.due = datetime.combine(due.dt, datetime.min.time())
self.due = self.due.replace(tzinfo=get_config().tz)
else:
self.due = None
# self.attributes: Dict[str, str] = dict()
attributes: Dict[str, str] = dict()
tmp: Dict[str, Any] = {
'calendar': cname,
'start': start,
'end': end
}
if self.due:
tmp['due'] = str(self.due)
for attr in _ATTRIBUTES:
tmp[attr] = todo.get(attr, '')
substitution_keys = set(_ATTRIBUTES)
substitution_keys.update(tmp.keys())
substitution_keys.update(get_config().key_replace.keys())
substitution_keys.update(get_config().value_replace.keys())
for attr in substitution_keys:
newkey: str = get_config().key_replace.get(attr, attr)
value: str = tmp.get(attr, '')
newval_template: str = get_config().value_replace.get(attr, str(value))
jtemplate: jinja2.Template = get_jenv().from_string(newval_template)
newvalue: str = jtemplate.render(**tmp)
attributes[newkey] = newvalue
self.uid: str = f'{cname}-{start.strftime("%Y%m%dT%H%M%S%Z")}'
self.priority = todo.get('priority', '0')
super().__init__('todo', attributes)
def serialize(self) -> Dict[str, Any]:
todo: Dict[str, Any] = {
'metric': {
'__name__': 'todo',
'calendar': self.calendar
},
'value': [
self.start.timestamp(),
"1"
]
}
todo['metric'].update(self._labels)
return todo

View file

@ -1,14 +1,14 @@
Package: icalendar-timeseries-server Package: icalendar-timeseries-server
Version: 0.1 Version: __VERSION__
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol> Maintainer: s3lph <s3lph@kabelsalat.ch>
Section: web Section: web
Priority: optional Priority: optional
Architecture: all Architecture: all
Depends: python3 (>= 3.7), python3-jinja2, python3-bottle, python3-dateutil, python3-icalendar, python3-isodate, python3-tz Depends: python3 (>= 3.7), python3-jinja2, python3-bottle, python3-dateutil, python3-icalendar, python3-isodate
Description: Scrape iCalendar endpoints and present their data in a Description: Scrape iCalendar endpoints and present their data in a timeseries format.
timeseries format. A small service that scrapes iCalendar files A small service that scrapes iCalendar files served over HTTP, parses
served over HTTP, parses their contents and returns a timeseries their contents and returns a timeseries format compatible to the
format compatible to the /api/v1/query API endpoint of a Prometheus /api/v1/query API endpoint of a Prometheus server. This allows e.g. a
server. This allows e.g. a Grafana administrator to add a Prometheus Grafana administrator to add a Prometheus data source pointing at
data source pointing at this server, returning the events in the this server, returning the events in the calendars in the event
calendars in the event metric. metric.

View file

@ -15,6 +15,7 @@ if [[ "$1" == "configure" ]]; then
chown its:its /var/lib/its chown its:its /var/lib/its
chmod 0750 /var/lib/its chmod 0750 /var/lib/its
systemctl daemon-reload || true deb-systemd-helper enable icalendar-timeseries-server.service
deb-systemd-invoke restart icalendar-timeseries-server.service
fi fi

View file

@ -4,6 +4,6 @@ set -e
if [[ "$1" == "remove" ]]; then if [[ "$1" == "remove" ]]; then
userdel its deb-systemd-invoke stop icalendar-timeseries-server.service
fi fi

View file

@ -1,157 +0,0 @@
#!/usr/bin/env python3
from typing import Any, Dict, List, Optional, Tuple
import os
import sys
import json
import urllib.request
import http.client
from urllib.error import HTTPError
def parse_changelog(tag: str) -> Optional[str]:
release_changelog: str = ''
with open('CHANGELOG.md', 'r') as f:
in_target: bool = False
done: bool = False
for line in f.readlines():
if in_target:
if f'<!-- END RELEASE {tag} -->' in line:
done = True
break
release_changelog += line
elif f'<!-- BEGIN RELEASE {tag} -->' in line:
in_target = True
continue
if not done:
return None
return release_changelog
def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]:
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
headers: Dict[str, str] = {
'Private-Token': api_token
}
req = urllib.request.Request(url, headers=headers)
try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e:
print(e.read().decode())
sys.exit(1)
resp_data: bytes = resp.read()
joblist: List[Dict[str, Any]] = json.loads(resp_data.decode())
jobidmap: Dict[str, str] = {}
for job in joblist:
name: str = job['name']
job_id: str = job['id']
jobidmap[name] = job_id
return jobidmap
def fetch_single_shafile(url: str) -> str:
req = urllib.request.Request(url)
try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e:
print(e.read().decode())
sys.exit(1)
resp_data: bytes = resp.readline()
shafile: str = resp_data.decode()
filename: str = shafile.strip().split(' ')[-1].strip()
return filename
def fetch_wheel_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
mybase: str = f'{base_url}/jobs/{job_ids["build_wheel"]}/artifacts/raw'
wheel_sha_url: str = f'{mybase}/dist/SHA256SUMS'
wheel_filename: str = fetch_single_shafile(wheel_sha_url)
wheel_url: str = f'{mybase}/dist/{wheel_filename}'
return wheel_url, wheel_sha_url
def fetch_debian_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
mybase: str = f'{base_url}/jobs/{job_ids["build_debian"]}/artifacts/raw'
debian_sha_url: str = f'{mybase}/package/debian/SHA256SUMS'
debian_filename: str = fetch_single_shafile(debian_sha_url)
debian_url: str = f'{mybase}/package/debian/{debian_filename}'
return debian_url, debian_sha_url
def main():
api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN')
release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG')
project_name: Optional[str] = os.getenv('CI_PROJECT_PATH')
project_id: Optional[str] = os.getenv('CI_PROJECT_ID')
pipeline_id: Optional[str] = os.getenv('CI_PIPELINE_ID')
if api_token is None:
print('GITLAB_API_TOKEN is not set.', file=sys.stderr)
sys.exit(1)
if release_tag is None:
print('CI_COMMIT_TAG is not set.', file=sys.stderr)
sys.exit(1)
if project_name is None:
print('CI_PROJECT_PATH is not set.', file=sys.stderr)
sys.exit(1)
if project_id is None:
print('CI_PROJECT_ID is not set.', file=sys.stderr)
sys.exit(1)
if pipeline_id is None:
print('CI_PIPELINE_ID is not set.', file=sys.stderr)
sys.exit(1)
changelog: Optional[str] = parse_changelog(release_tag)
if changelog is None:
print('Changelog could not be parsed.', file=sys.stderr)
sys.exit(1)
job_ids: Dict[str, str] = fetch_job_ids(project_id, pipeline_id, api_token)
base_url: str = f'https://gitlab.com/{project_name}/-'
wheel_url, wheel_sha_url = fetch_wheel_url(base_url, job_ids)
debian_url, debian_sha_url = fetch_debian_url(base_url, job_ids)
augmented_changelog = f'''{changelog.strip()}
### Download
- [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url}))
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))'''
post_body: str = json.dumps({'description': augmented_changelog})
gitlab_release_api_url: str = \
f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
headers: Dict[str, str] = {
'Private-Token': api_token,
'Content-Type': 'application/json; charset=utf-8'
}
request = urllib.request.Request(
gitlab_release_api_url,
post_body.encode('utf-8'),
headers=headers,
method='POST'
)
try:
response: http.client.HTTPResponse = urllib.request.urlopen(request)
except HTTPError as e:
print(e.read().decode())
sys.exit(1)
response_bytes: bytes = response.read()
response_str: str = response_bytes.decode()
response_data: Dict[str, Any] = json.loads(response_str)
if response_data['tag_name'] != release_tag:
print('Something went wrong...', file=sys.stderr)
print(response_str, file=sys.stderr)
sys.exit(1)
print(response_data['description'])
if __name__ == '__main__':
main()

View file

@ -12,18 +12,25 @@ setup(
description='', description='',
license='MIT', license='MIT',
keywords='ical,icalendar,timeseries,prometheus,grafana', keywords='ical,icalendar,timeseries,prometheus,grafana',
url='https://gitlab.com/s3lph/icalendar-timeseries-server', url='https://git.kabelsalat.ch/s3lph/icalendar-timeseries-server',
packages=find_packages(exclude=['*.test']), packages=find_packages(exclude=['*.test']),
long_description='', long_description='',
python_requires='>=3.6', python_requires='>=3.6',
install_requires=[ install_requires=[
'bottle', 'bottle',
'python-dateutil', 'python-dateutil>=2.8',
'icalendar', 'icalendar',
'isodate', 'isodate',
'jinja2', 'jinja2'
'pytz'
], ],
extras_require={
'test': [
'coverage',
'pycodestyle',
'mypy',
'twine'
]
},
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'icalendar-timeseries-server = icalendar_timeseries_server:main' 'icalendar-timeseries-server = icalendar_timeseries_server:main'