Compare commits

..

11 Commits

Author SHA256 Message Date
Hadrian Burkhardt 20b441fc5c git changes 2026-05-21 08:39:45 +00:00
Hadrian Burkhardt 1b7f563e03 clear github 2026-05-21 08:28:58 +00:00
Hadrian Burkhardt cf5348a0c8 modular parser framework 2026-05-21 08:21:49 +00:00
Hadrian Burkhardt 1223791074 united-domains 2026-05-03 07:22:39 +00:00
Hadrian Burkhardt e67117b7d5 gitea host compose 2026-05-02 20:57:36 +00:00
Hadrian Burkhardt 1f7d5596fc gitea host added 2026-05-02 20:35:31 +00:00
Hadrian Burkhardt f5d89cee2f docker compose 2026-05-01 01:47:55 +00:00
Hadrian Burkhardt b31796da39 fixed salattheke prices 2026-05-01 00:38:18 +00:00
Hadrian Burkhardt e9b7866bb1 refactor: make webspeiseplan fetching explicit 2026-05-01 00:13:33 +00:00
Hadrian Burkhardt 7720002d30 chore: clean packaging validation and config model 2026-05-01 00:08:43 +00:00
Hadrian Burkhardt bfe72b4f77 chore: unify packaging around uv 2026-04-30 23:59:56 +00:00
49 changed files with 740 additions and 1553 deletions
-41
View File
@@ -1,41 +0,0 @@
version: 2.1
orbs:
python: circleci/python@1.5.0
jobs:
build-and-test:
docker:
- image: cimg/python:3.13
steps:
- checkout
- run:
name: Install Dependencies
command: make dependencies
- run:
name: Lint
command: make lint
- run:
name: Test
command: make test
- run:
name: Publish Coverage
command: make coverage_publish
- run:
name: Report Coverage
command: make coverage_report
workflows:
default:
jobs:
- build-and-test
nightly-build:
triggers:
- schedule:
cron: "5 10 * * 1-5"
filters:
branches:
only: master
jobs:
- build-and-test:
context: nightly
+3
View File
@@ -0,0 +1,3 @@
PARSER_HOST=menus.example.org
PARSER_URL=https://menus.example.org
GITEA_HOST=gitea.example.org
-16
View File
@@ -1,16 +0,0 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
+5
View File
@@ -3,8 +3,13 @@
.pytest_cache .pytest_cache
/.coverage /.coverage
/htmlcov /htmlcov
/build
/dist
*.egg-info/
*.pyc *.pyc
.venv .venv
__pycache__ __pycache__
.devcontainer .devcontainer
.vscode .vscode
.codex
.env.local
-4
View File
@@ -30,10 +30,6 @@ persistent=yes
# Specify a configuration file. # Specify a configuration file.
#rcfile= #rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the # Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code. # active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no unsafe-load-any-extension=no
+7
View File
@@ -0,0 +1,7 @@
om-parser-stw-potsdam.softwareapp-hb.de {
reverse_proxy app:3080
}
gitea.softwareapp-hb.de {
reverse_proxy gitea-container.incus:3000
}
+9 -8
View File
@@ -1,4 +1,4 @@
ARG DEPLOY_DIR=/opt/om-parser-stw-potsdam-v2 ARG DEPLOY_DIR=/opt/openmensa-parsers
ARG USERNAME=flaskd ARG USERNAME=flaskd
ARG LISTEN_PORT=3080 ARG LISTEN_PORT=3080
@@ -9,7 +9,7 @@ ARG DEPLOY_DIR
ARG USERNAME ARG USERNAME
# Install dependencies # Install dependencies
RUN pip install pipenv RUN pip install --no-cache-dir uv
# Add app user # Add app user
RUN adduser --disabled-password ${USERNAME} RUN adduser --disabled-password ${USERNAME}
@@ -24,11 +24,12 @@ ENV PIPENV_VENV_IN_PROJECT=1
WORKDIR ${DEPLOY_DIR} WORKDIR ${DEPLOY_DIR}
# Copy app folder contents # Copy app folder contents
COPY stw_potsdam/ ./stw_potsdam COPY openmensa_parsers/ ./openmensa_parsers
COPY tests ./tests COPY tests ./tests
COPY Makefile . COPY Makefile .
COPY Pipfile . COPY README.md .
COPY Pipfile.lock . COPY pyproject.toml .
COPY uv.lock .
# Apply app user to app folder # Apply app user to app folder
RUN chown -R ${USERNAME} . RUN chown -R ${USERNAME} .
@@ -48,7 +49,7 @@ RUN apt-get update && apt-get install -y build-essential python3-dev
# Install environment # Install environment
USER ${USERNAME} USER ${USERNAME}
RUN pipenv install --dev RUN uv sync --frozen --extra dev
# Run tests # Run tests
RUN make test RUN make test
@@ -58,7 +59,7 @@ RUN rm -rf .venv
RUN rm -rf ./tests ./Makefile RUN rm -rf ./tests ./Makefile
# Install production environment # Install production environment
RUN pipenv install --deploy RUN uv sync --frozen --no-dev
### Production container ### Production container
@@ -82,6 +83,6 @@ ENV LISTEN_PORT=${LISTEN_PORT}
ENV LISTEN=0.0.0.0:${LISTEN_PORT} ENV LISTEN=0.0.0.0:${LISTEN_PORT}
EXPOSE ${LISTEN_PORT} EXPOSE ${LISTEN_PORT}
CMD ["pipenv", "run", "uwsgi", "--master", "--http11-socket", "$LISTEN", "--plugins", "python", "--protocol", "uwsgi", "--wsgi", "stw_potsdam.views:app", "--virtualenv", "./.venv"] CMD ["sh", "-c", "uv run uwsgi --master --http11-socket \"$LISTEN\" --plugins python --protocol uwsgi --wsgi openmensa_parsers.views:app --virtualenv ./.venv"]
HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:$LISTEN_PORT/health_check || exit 1 HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:$LISTEN_PORT/health_check || exit 1
+11 -15
View File
@@ -1,34 +1,30 @@
RUN=uv run flask --app openmensa_parsers.views run
RUN=FLASK_APP="stw_potsdam.views" pipenv run flask run
dependencies: dependencies:
pipenv sync --dev uv sync --extra dev
run: run:
$(RUN) $(RUN)
debug: debug:
FLASK_ENV=development $(RUN) --debug uv run flask --app openmensa_parsers.views --debug run
test: test:
pipenv run python -m pytest -vv --cov-branch --cov stw_potsdam --cov-report term --cov-report html uv run --extra dev python -m pytest -vv --cov-branch --cov openmensa_parsers --cov-report term --cov-report html
test_debug: test_debug:
pipenv run python -m pytest -v --trace uv run --extra dev python -m pytest -v --trace
coverage_publish:
pipenv run python -m coveralls
coverage_report: coverage_report:
pipenv run python -m coverage report --fail-under 90 uv run --extra dev python -m coverage report --fail-under 90
lint: lint:
pipenv run pycodestyle stw_potsdam tests uv run --extra dev pycodestyle openmensa_parsers tests
pipenv run pydocstyle stw_potsdam tests uv run --extra dev pydocstyle openmensa_parsers tests
pipenv run pylint stw_potsdam tests uv run --extra dev pylint openmensa_parsers tests
clean: clean:
pipenv run python -m coverage erase uv run --extra dev python -m coverage erase
rm -rf .pytest_cache .cache rm -rf .pytest_cache .cache
.PHONY: dependencies run debug test test_debug coverage_publish coverage_report lint clean .PHONY: dependencies run debug test test_debug coverage_report lint clean
-21
View File
@@ -1,21 +0,0 @@
[packages]
requests = "*"
pyopenmensa = "*"
flask = "*"
cachetools = "*"
uwsgi = "*"
[dev-packages]
pytest = "*"
coveralls = "*"
pytest-cov = "*"
httpretty = "*"
pycodestyle = "*"
pydocstyle = "*"
pylint = "*"
sphinx = "*"
sphinx-autobuild = "*"
sphinx-rtd-theme = "*"
[requires]
python_version = "3.13"
Generated
-1073
View File
File diff suppressed because it is too large Load Diff
+9 -19
View File
@@ -1,15 +1,13 @@
# OpenMensa Parser STW Potsdam # OpenMensa Parsers
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/f4lco/om-parser-stw-potsdam-v2/tree/master.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/f4lco/om-parser-stw-potsdam-v2/tree/master) [![Read the Docs](https://readthedocs.org/projects/openmensa-parsers/badge/?version=latest&style=flat)](https://openmensa-parsers.readthedocs.io/en/latest/)
[![Coverage Status](https://coveralls.io/repos/github/f4lco/om-parser-stw-potsdam-v2/badge.svg?branch=master)](https://coveralls.io/github/f4lco/om-parser-stw-potsdam-v2?branch=master)
[![Read the Docs](https://readthedocs.org/projects/om-parser-stw-potsdam-v2/badge/?version=latest&style=flat)](https://om-parser-stw-potsdam-v2.readthedocs.io/en/latest/)
[OpenMensa][om] parser components query canteen websites for menus and transform them into OpenMensa's data format. [OpenMensa][om] parser components query canteen websites for menus and transform them into OpenMensa's data format.
This project came to life after the website of the canteens of the Studentenwerk Potsdam changed, and is therefore the successor to [kaifabian/om-parser-potsdam][prev-parser] (hence the "-v2"). The default parser currently supports Studentenwerk Potsdam. The package is structured so additional city or provider parsers can be added behind the same OpenMensa XML renderer.
Among others, OpenMensa powers the popular [Mensa Uni Potsdam][steppschuh] Android app. Among others, OpenMensa powers the popular [Mensa Uni Potsdam][steppschuh] Android app.
The current application is built with [Python][py], [PyOpenMensa][pom], and [Flask][flask]. Learn more about the technical details at [Read the Docs][rtd]. The current application is built with [Python][py], PyOpenMensa, and [Flask][flask]. Learn more about the technical details at [Read the Docs][rtd].
## Local development (modern) ## Local development (modern)
@@ -19,33 +17,25 @@ Recommended: Python 3.12+.
$ uv venv $ uv venv
$ uv pip install -e ".[dev]" $ uv pip install -e ".[dev]"
$ uv run flask --app stw_potsdam.views run $ uv run flask --app openmensa_parsers.views run
**Option B (venv + pip)** :: **Option B (venv + pip)** ::
$ python -m venv .venv $ python -m venv .venv
$ . .venv/bin/activate $ . .venv/bin/activate
$ pip install -r requirements-dev.txt $ pip install -e ".[dev]"
$ FLASK_APP=stw_potsdam.views flask run $ FLASK_APP=openmensa_parsers.views flask run
**Legacy (Pipenv)** ::
$ pipenv install --dev
$ make run
**Contributions** are always welcome, in particular if the response format of the canteens change. Feel free to file a PR with improvements. **Contributions** are always welcome, in particular if the response format of the canteens change. Feel free to file a PR with improvements.
**Deployment** If in need of a deployment, file a PR to this fork: [kaifabian/om-parser-potsdam-v2][kai]. Kai is currently in charge of running an instance of the parser and the registration on the OpenMensa platform. **Deployment** Coordinate deployment with the maintainer responsible for the running parser instance and its OpenMensa registration.
**Where to go next** maybe use this parser or the OpenMensa API to source a new dataset for training a predictor for your favorite lunch? **Where to go next** maybe use this parser or the OpenMensa API to source a new dataset for training a predictor for your favorite lunch?
**License** Just assume this project is licensed in terms of [WTFPL](http://www.wtfpl.net/) ;) **License** Just assume this project is licensed in terms of [WTFPL](http://www.wtfpl.net/) ;)
[om]: https://openmensa.org [om]: https://openmensa.org
[prev-parser]: https://github.com/kaifabian/om-parser-potsdam [rtd]: https://openmensa-parsers.readthedocs.io/en/latest/
[rtd]: https://om-parser-stw-potsdam-v2.readthedocs.io/en/latest/
[steppschuh]: https://steppschuh.net/blog/?p=951 [steppschuh]: https://steppschuh.net/blog/?p=951
[py]: http://python.org [py]: http://python.org
[pom]: https://github.com/mswart/pyopenmensa
[flask]: https://palletsprojects.com/p/flask/ [flask]: https://palletsprojects.com/p/flask/
[kai]: https://github.com/kaifabian/om-parser-stw-potsdam-v2
+30
View File
@@ -0,0 +1,30 @@
services:
app:
build: .
restart: unless-stopped
environment:
BASE_URL: ${PARSER_URL}
LISTEN_PORT: 3080
expose:
- "3080"
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- app
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
PARSER_HOST: ${PARSER_HOST}
GITEA_HOST: ${GITEA_HOST}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
+6 -11
View File
@@ -20,7 +20,7 @@ sys.path.insert(0, os.path.abspath('..'))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = u'OpenMensa Potsdam' project = u'OpenMensa Parsers'
copyright = u'2019, f4lco' copyright = u'2019, f4lco'
author = u'f4lco' author = u'f4lco'
@@ -41,7 +41,6 @@ release = u'0.1'
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', 'sphinx.ext.autodoc',
'sphinx.ext.githubpages',
'alabaster', 'alabaster',
] ]
@@ -87,10 +86,6 @@ html_theme = 'alabaster'
# documentation. # documentation.
# #
html_theme_options = { html_theme_options = {
'github_banner': True,
'github_button': True,
'github_user': 'f4lco',
'github_repo': 'om-parser-stw-potsdam-v2',
'travis_button': False, 'travis_button': False,
} }
@@ -123,7 +118,7 @@ html_sidebars = {
# -- Options for HTMLHelp output --------------------------------------------- # -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder. # Output file base name for HTML help builder.
htmlhelp_basename = 'OpenMensaPotsdamdoc' htmlhelp_basename = 'OpenMensaParsersdoc'
# -- Options for LaTeX output ------------------------------------------------ # -- Options for LaTeX output ------------------------------------------------
@@ -150,7 +145,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
(master_doc, 'OpenMensaPotsdam.tex', u'OpenMensa Potsdam Documentation', (master_doc, 'OpenMensaParsers.tex', u'OpenMensa Parsers Documentation',
u'f4lco', 'manual'), u'f4lco', 'manual'),
] ]
@@ -160,7 +155,7 @@ latex_documents = [
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [
(master_doc, 'openmensapotsdam', u'OpenMensa Potsdam Documentation', (master_doc, 'openmensaparsers', u'OpenMensa Parsers Documentation',
[author], 1) [author], 1)
] ]
@@ -171,8 +166,8 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
(master_doc, 'OpenMensaPotsdam', u'OpenMensa Potsdam Documentation', (master_doc, 'OpenMensaParsers', u'OpenMensa Parsers Documentation',
author, 'OpenMensaPotsdam', 'One line description of project.', author, 'OpenMensaParsers', 'One line description of project.',
'Miscellaneous'), 'Miscellaneous'),
] ]
+44 -14
View File
@@ -6,9 +6,9 @@ Because the parser may break on changes to the canteen website, it should be eas
Quickstart Quickstart
~~~~~~~~~~ ~~~~~~~~~~
Use `Pipenv <https://pipenv.readthedocs.io/en/latest/>`_ to setup the environment and start coding: :: Use `uv <https://docs.astral.sh/uv/>`_ to setup the environment and start coding: ::
$ pipenv install --dev # Create venv $ uv sync --extra dev # Create venv and install dependencies
$ make test # Check setup by running tests $ make test # Check setup by running tests
$ make debug # Start app instance with debugger and pretty printing of JSON $ make debug # Start app instance with debugger and pretty printing of JSON
$ make run # Start app without debugger $ make run # Start app without debugger
@@ -17,7 +17,7 @@ Use `Pipenv <https://pipenv.readthedocs.io/en/latest/>`_ to setup the environmen
The list of available canteens is available at the ``/canteens`` endpoint. The ``/canteens/<name>`` endpoint provides the XML feed for individual canteens, e.g., ``/canteens/griebnitzsee``. The list of available canteens is available at the ``/canteens`` endpoint. The ``/canteens/<name>`` endpoint provides the XML feed for individual canteens, e.g., ``/canteens/griebnitzsee``.
Given the default configuration, http://127.0.0.1:5000/canteens/griebnitzsee will display the `OpenMensa` meta-feed for the Griebnitzsee canteen, and http://127.0.0.1:5000/canteens/griebnitzsee/menu will render the menu feed. Given the default configuration, http://127.0.0.1:5000/canteens/griebnitzsee will display the `OpenMensa` feed for the Griebnitzsee canteen.
Main Module Entry Points Main Module Entry Points
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
@@ -25,20 +25,53 @@ Main Module Entry Points
In the following the main workflow of this parser is explained. In the following the main workflow of this parser is explained.
Generating a new `OpenMensa` feed starts by reading the configured canteens. Some canteen data, such as ID, name, and location, are currently not scraped. Doing so would be very brittle and involve a multistep process. Refer to the :ref:`cache_hash` for deeper insight into the obstacles. Generating a new `OpenMensa` feed starts by reading the configured canteens. Some canteen data, such as ID, name, and location, are currently not scraped. Doing so would be very brittle and involve a multistep process. Refer to the :ref:`cache_hash` for deeper insight into the obstacles.
.. autofunction:: stw_potsdam.config.read_canteen_config .. autofunction:: openmensa_parsers.config.read_canteen_config
.. autoclass:: stw_potsdam.config.Canteen .. autoclass:: openmensa_parsers.config.Canteen
Use the canteen data to assemble the menu parameters in order to download the menu JSON. The menu parameters are also used as cache key: it should uniquely identify the retrieved menu. Use the canteen data to select matching upstream outlets, download the required menu JSON, and render the OpenMensa XML.
.. autoclass:: stw_potsdam.canteen_api.MenuParams Parser Providers
~~~~~~~~~~~~~~~~
.. autofunction:: stw_potsdam.canteen_api.download_menu The application is structured around parser providers. A provider owns the
source-specific work: fetching raw upstream data and converting it into the
shared OpenMensa XML structures. The ``Builder`` only asks a provider for
canteens, attaches feed metadata, and renders XML.
The render module contains methods for converting the JSON response into valid `OpenMensa` menu and meta feeds, respectively: New cities or data sources should add a parser under ``openmensa_parsers.parsers``.
The parser should implement three methods:
.. autofunction:: stw_potsdam.feed.render_menu ``fetch()``
.. autofunction:: stw_potsdam.feed.render_meta Download or load the raw source data.
``parse(config, raw_data)``
Convert raw data into a ``dict[str, CanteenXML]`` keyed by the configured
canteen key.
``create_feed(canteen, url)``
Return the feed metadata for one canteen. In most cases, subclass
``BaseOpenMensaParser`` and configure ``feed`` instead of overriding this
method.
Register the parser in ``openmensa_parsers.parsers.registry``. At runtime, select a
parser with ``OM_PARSER_ID``. The default is ``potsdam``.
Parser tests should keep network access separate from parsing. Store raw
fixtures in the test suite, pass them directly into ``parse()``, and reserve
live source checks for opt-in tests.
.. autoclass:: openmensa_parsers.webspeiseplan_api.WebspeiseplanAPI
.. autoclass:: openmensa_parsers.webspeiseplan_parser.WebspeiseplanParser
.. autoclass:: openmensa_parsers.parsers.base.BaseOpenMensaParser
.. autoclass:: openmensa_parsers.parsers.potsdam.PotsdamParser
The XML type modules contain the OpenMensa rendering objects:
.. autoclass:: openmensa_parsers.xml_types.builder.Builder
Tests Tests
~~~~~ ~~~~~
@@ -56,6 +89,3 @@ Test execution works as follows: ::
The first invocation runs tests whose outcome can solely be determined by the test suite, which makes them suitable for frequent execution and CI systems. The first invocation runs tests whose outcome can solely be determined by the test suite, which makes them suitable for frequent execution and CI systems.
Setting the environment variable ``ENABLE_API_QUERY`` enables tests which require querying the canteen API. Because third-party services are queried, those are more suited to manual execution. Developers can quickly check if their change is applicable to today's menu. Setting the environment variable ``ENABLE_API_QUERY`` enables tests which require querying the canteen API. Because third-party services are queried, those are more suited to manual execution. Developers can quickly check if their change is applicable to today's menu.
+67
View File
@@ -0,0 +1,67 @@
FritzBox Self-Hosting
=====================
This setup runs the parser behind Caddy. Caddy receives public HTTP/HTTPS
traffic, obtains TLS certificates, and proxies requests to the app container.
Requirements
------------
* A machine on your home network with Docker and Docker Compose.
* A DNS name that points to your home connection.
* FritzBox port sharing from the internet to the Docker host:
* TCP 80 -> Docker host TCP 80
* TCP 443 -> Docker host TCP 443
* UDP 443 -> Docker host UDP 443
If your internet provider uses CGNAT and you do not have a reachable public IP,
plain FritzBox port forwarding will not work. Use IPv6 with an AAAA record, a
provider option for public IPv4, or a tunnel/VPN.
Configure
---------
Create a local environment file:
.. code-block:: shell
cp .env.local.example .env.local
Edit ``.env.local``:
.. code-block:: dotenv
PUBLIC_HOST=menus.example.org
BASE_URL=https://menus.example.org
``PUBLIC_HOST`` is the domain Caddy serves. ``BASE_URL`` is used by the Flask
app when it generates absolute OpenMensa feed URLs.
Run
---
Start or update the local deployment:
.. code-block:: shell
docker compose --env-file .env.local -f compose.local.yml up -d --build
Check logs:
.. code-block:: shell
docker compose --env-file .env.local -f compose.local.yml logs -f
Check health locally:
.. code-block:: shell
curl http://127.0.0.1/health_check
Stop
----
.. code-block:: shell
docker compose --env-file .env.local -f compose.local.yml down
+1 -1
View File
@@ -1,4 +1,4 @@
Welcome to OpenMensa Parser Potsdam's documentation! Welcome to OpenMensa Parsers' documentation!
==================================================== ====================================================
An OpenMensa parser retrieves canteen menus and renders them in a commonly understood format. An OpenMensa parser retrieves canteen menus and renders them in a commonly understood format.
@@ -1,74 +0,0 @@
Metadata-Version: 2.4
Name: om-parser-stw-potsdam-v2
Version: 2.0.1
Summary: OpenMensa parser components for Studentenwerk Potsdam.
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: requests
Requires-Dist: pyopenmensa
Requires-Dist: flask
Requires-Dist: cachetools
Requires-Dist: uwsgi
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: coveralls; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: httpretty; extra == "dev"
Requires-Dist: pycodestyle; extra == "dev"
Requires-Dist: pydocstyle; extra == "dev"
Requires-Dist: pylint; extra == "dev"
Requires-Dist: sphinx; extra == "dev"
Requires-Dist: sphinx-autobuild; extra == "dev"
Requires-Dist: sphinx-rtd-theme; extra == "dev"
# OpenMensa Parser STW Potsdam
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/f4lco/om-parser-stw-potsdam-v2/tree/master.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/f4lco/om-parser-stw-potsdam-v2/tree/master)
[![Coverage Status](https://coveralls.io/repos/github/f4lco/om-parser-stw-potsdam-v2/badge.svg?branch=master)](https://coveralls.io/github/f4lco/om-parser-stw-potsdam-v2?branch=master)
[![Read the Docs](https://readthedocs.org/projects/om-parser-stw-potsdam-v2/badge/?version=latest&style=flat)](https://om-parser-stw-potsdam-v2.readthedocs.io/en/latest/)
[OpenMensa][om] parser components query canteen websites for menus and transform them into OpenMensa's data format.
This project came to life after the website of the canteens of the Studentenwerk Potsdam changed, and is therefore the successor to [kaifabian/om-parser-potsdam][prev-parser] (hence the "-v2").
Among others, OpenMensa powers the popular [Mensa Uni Potsdam][steppschuh] Android app.
The current application is built with [Python][py], [PyOpenMensa][pom], and [Flask][flask]. Learn more about the technical details at [Read the Docs][rtd].
## Local development (modern)
Recommended: Python 3.12+.
**Option A (uv, recommended)** ::
$ uv venv
$ uv pip install -e ".[dev]"
$ uv run flask --app stw_potsdam.views run
**Option B (venv + pip)** ::
$ python -m venv .venv
$ . .venv/bin/activate
$ pip install -r requirements-dev.txt
$ FLASK_APP=stw_potsdam.views flask run
**Legacy (Pipenv)** ::
$ pipenv install --dev
$ make run
**Contributions** are always welcome, in particular if the response format of the canteens change. Feel free to file a PR with improvements.
**Deployment** If in need of a deployment, file a PR to this fork: [kaifabian/om-parser-potsdam-v2][kai]. Kai is currently in charge of running an instance of the parser and the registration on the OpenMensa platform.
**Where to go next** maybe use this parser or the OpenMensa API to source a new dataset for training a predictor for your favorite lunch?
**License** Just assume this project is licensed in terms of [WTFPL](http://www.wtfpl.net/) ;)
[om]: https://openmensa.org
[prev-parser]: https://github.com/kaifabian/om-parser-potsdam
[rtd]: https://om-parser-stw-potsdam-v2.readthedocs.io/en/latest/
[steppschuh]: https://steppschuh.net/blog/?p=951
[py]: http://python.org
[pom]: https://github.com/mswart/pyopenmensa
[flask]: https://palletsprojects.com/p/flask/
[kai]: https://github.com/kaifabian/om-parser-stw-potsdam-v2
@@ -1,22 +0,0 @@
README.md
pyproject.toml
om_parser_stw_potsdam_v2.egg-info/PKG-INFO
om_parser_stw_potsdam_v2.egg-info/SOURCES.txt
om_parser_stw_potsdam_v2.egg-info/dependency_links.txt
om_parser_stw_potsdam_v2.egg-info/requires.txt
om_parser_stw_potsdam_v2.egg-info/top_level.txt
stw_potsdam/__init__.py
stw_potsdam/config.py
stw_potsdam/swp_webspeiseplan_api.py
stw_potsdam/swp_webspeiseplan_parser.py
stw_potsdam/views.py
stw_potsdam/xml_types/__init__.py
stw_potsdam/xml_types/builder.py
stw_potsdam/xml_types/canteen_xml.py
stw_potsdam/xml_types/feed_xml.py
stw_potsdam/xml_types/meal_xml.py
stw_potsdam/xml_types/openmensa_xml.py
stw_potsdam/xml_types/times_xml.py
tests/test_consistency.py
tests/test_retrieval.py
tests/test_views.py
@@ -1 +0,0 @@
@@ -1,17 +0,0 @@
requests
pyopenmensa
flask
cachetools
uwsgi
[dev]
pytest
coveralls
pytest-cov
httpretty
pycodestyle
pydocstyle
pylint
sphinx
sphinx-autobuild
sphinx-rtd-theme
@@ -1 +0,0 @@
stw_potsdam
@@ -4,11 +4,20 @@ import configparser
import io import io
import os import os
from collections import namedtuple from dataclasses import dataclass
from functools import partial from functools import partial
Canteen = namedtuple('Canteen',
('key', 'name', 'street', 'city', 'id', 'chash')) @dataclass(frozen=True)
class Canteen:
"""Configured OpenMensa canteen mapping."""
key: str
name: str
street: str
city: str
id: str
chash: str
def _get_config(filename): def _get_config(filename):
+5
View File
@@ -0,0 +1,5 @@
"""Parser/provider implementations for OpenMensa feed sources."""
from openmensa_parsers.parsers.registry import create_parser, get_parser_class
__all__ = ["create_parser", "get_parser_class"]
+61
View File
@@ -0,0 +1,61 @@
"""Shared parser contract for city-specific OpenMensa parsers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
from openmensa_parsers.config import Canteen
from openmensa_parsers.xml_types.canteen_xml import CanteenXML
from openmensa_parsers.xml_types.feed_xml import FeedXML, ScheduleXML
@dataclass(frozen=True)
class FeedDefinition:
"""Default feed metadata used when publishing a parser result."""
source: str
name: str = "full"
priority: int = 0
schedule: dict[str, Any] = field(
default_factory=lambda: {"hour": "8-14", "retry": "30 1"}
)
class OpenMensaParser(Protocol):
"""Contract implemented by each city/source parser."""
id: str
feed: FeedDefinition
def fetch(self) -> Any:
"""Download or load source-specific raw data."""
def parse(
self,
config: dict[str, Canteen],
raw_data: Any,
) -> dict[str, CanteenXML]:
"""Convert raw source data into OpenMensa canteen structures."""
def create_feed(self, canteen: Canteen, url: str) -> FeedXML:
"""Build the OpenMensa feed metadata for one configured canteen."""
class BaseOpenMensaParser: # pylint: disable=too-few-public-methods
"""Base helper for parsers that use the standard OpenMensa feed block."""
id = "base"
feed: FeedDefinition
def create_feed(self, _canteen: Canteen, url: str) -> FeedXML:
"""Create a standard feed tag for a configured canteen."""
schedule_data = dict(self.feed.schedule)
schedule = ScheduleXML(**schedule_data)
return FeedXML(
name=self.feed.name,
priority=self.feed.priority,
source=self.feed.source,
url=url,
schedule=schedule,
)
+61
View File
@@ -0,0 +1,61 @@
"""Potsdam parser/provider implementation."""
from __future__ import annotations
import logging
from openmensa_parsers.config import Canteen
from openmensa_parsers.parsers.base import BaseOpenMensaParser, FeedDefinition
from openmensa_parsers.webspeiseplan_api import (
WebspeiseplanAPI,
WebspeiseplanData,
)
from openmensa_parsers.webspeiseplan_parser import WebspeiseplanParser
from openmensa_parsers.xml_types.canteen_xml import CanteenXML
class PotsdamParser(BaseOpenMensaParser):
"""Parser for Studentenwerk Potsdam's Webspeiseplan source."""
id = "potsdam"
BASE_URL = "https://swp.webspeiseplan.de"
feed = FeedDefinition(source=BASE_URL)
def __init__(
self,
api: WebspeiseplanAPI | None = None,
parser: WebspeiseplanParser | None = None,
) -> None:
"""Initialize the Potsdam parser with fetch and parse helpers."""
self.api = WebspeiseplanAPI(self.BASE_URL) if api is None else api
self.parser = WebspeiseplanParser() if parser is None else parser
self.logger = logging.getLogger(__name__)
def fetch(self) -> WebspeiseplanData:
"""Download all data required by the Potsdam parser."""
return self.api.fetch_all()
def parse(
self,
config: dict[str, Canteen],
raw_data: WebspeiseplanData,
) -> dict[str, CanteenXML]:
"""Convert Potsdam Webspeiseplan data into canteen structures."""
parsed: dict[str, CanteenXML] = {}
for canteen_key, configured_canteen in config.items():
source_name = configured_canteen.name
if source_name not in raw_data.outlets:
self.logger.warning("%s not found in keys", source_name)
continue
outlet = dict(raw_data.outlets[source_name])
menus = raw_data.menus[source_name]
categories = raw_data.meal_categories[source_name]
locations = raw_data.locations[source_name]
outlet["isPublic"] = locations["isPublic"]
canteen = self.parser.parse_canteen_meta_times(outlet)
for meal_data in self.parser.parse_meals(menus, categories):
canteen.add_meal(**meal_data)
parsed[canteen_key] = canteen
return parsed
+25
View File
@@ -0,0 +1,25 @@
"""Registry for city/source parser implementations."""
from __future__ import annotations
from openmensa_parsers.parsers.base import OpenMensaParser
from openmensa_parsers.parsers.potsdam import PotsdamParser
PARSER_CLASSES: dict[str, type[OpenMensaParser]] = {
PotsdamParser.id: PotsdamParser,
}
def get_parser_class(parser_id: str) -> type[OpenMensaParser]:
try:
return PARSER_CLASSES[parser_id]
except KeyError as exc:
configured = ", ".join(sorted(PARSER_CLASSES))
raise KeyError(
f"Unknown parser {parser_id!r}; configured parsers: {configured}"
) from exc
def create_parser(parser_id: str) -> OpenMensaParser:
return get_parser_class(parser_id)()
@@ -8,10 +8,12 @@ import cachetools as ct
from flask import Flask, jsonify, make_response, url_for from flask import Flask, jsonify, make_response, url_for
from flask.logging import create_logger from flask.logging import create_logger
from stw_potsdam.config import read_canteen_config from openmensa_parsers.config import read_canteen_config
from stw_potsdam.xml_types.builder import Builder from openmensa_parsers.parsers import create_parser
from openmensa_parsers.xml_types.builder import Builder
CACHE_TIMEOUT = 45 * 60 CACHE_TIMEOUT = 45 * 60
PARSER_ID = os.environ.get("OM_PARSER_ID", "potsdam")
# pragma pylint: disable=invalid-name # pragma pylint: disable=invalid-name
@@ -40,8 +42,8 @@ def canteen_not_found(canteen_name):
@ct.cached(cache=cache) @ct.cached(cache=cache)
def update_builder(): def update_builder():
log.debug("Downloading menu for SWP") log.debug("Downloading menu using parser %s", PARSER_ID)
return Builder(config) return Builder(config, parser=create_parser(PARSER_ID))
@app.route("/canteens/<canteen_name>") @app.route("/canteens/<canteen_name>")
@@ -1,40 +1,62 @@
import logging import logging
import urllib.request import urllib.request
import urllib.parse
import re import re
import time import time
import json import json
from dataclasses import dataclass
class SWPWebspeiseplanAPI:
"""This class is used download content from SWP_Webspeiseplan.
Returns: @dataclass(frozen=True)
[type]: [description] class WebspeiseplanData:
""" """Downloaded Webspeiseplan data grouped by outlet name."""
outlets: dict[str, dict]
locations: dict[str, dict]
menus: dict[str, dict]
meal_categories: dict[str, dict]
class WebspeiseplanAPI:
"""Client for Webspeiseplan installations."""
URL_BASE = "https://swp.webspeiseplan.de"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def __init__(self): def __init__(self, base_url: str):
"""Initialize the configuration for the web service.""" """Initialize the web service client."""
logging.basicConfig() logging.basicConfig()
self.base_url = base_url.rstrip("/")
parsed_url = urllib.parse.urlparse(self.base_url)
if not parsed_url.scheme or not parsed_url.netloc:
raise ValueError(f"Invalid Webspeiseplan base URL: {base_url!r}")
self.host = parsed_url.netloc
def fetch_all(self) -> WebspeiseplanData:
"""Download all data required to render OpenMensa feeds."""
proxy_token = self.parse_token() proxy_token = self.parse_token()
self.outlets = self.parse_outlets(proxy_token) outlets = self.parse_outlets(proxy_token)
self.locations: dict[str, dict] = {}
locations = { locations = {
item["id"]: item item["id"]: item
for item in self.parse_location(proxy_token) for item in self.parse_location(proxy_token)
} }
self.menus: dict[str, dict] = {} menus: dict[str, dict] = {}
self.meal_categories: dict[str, dict] = {} meal_categories: dict[str, dict] = {}
for outlet in self.outlets.values(): outlet_locations: dict[str, dict] = {}
for outlet in outlets.values():
location = outlet["standortID"] location = outlet["standortID"]
menu = self.parse_menu(proxy_token, location) menu = self.parse_menu(proxy_token, location)
categories = self.parse_meal_category(proxy_token, location) categories = self.parse_meal_category(proxy_token, location)
id2cat = {item["gerichtkategorieID"]: item for item in categories} id2cat = {item["gerichtkategorieID"]: item for item in categories}
self.menus[outlet["name"]] = menu menus[outlet["name"]] = menu
self.meal_categories[outlet["name"]] = id2cat meal_categories[outlet["name"]] = id2cat
self.locations[outlet["name"]] = locations[location] outlet_locations[outlet["name"]] = locations[location]
return WebspeiseplanData(
outlets=outlets,
locations=outlet_locations,
menus=menus,
meal_categories=meal_categories,
)
def __spoof_req_headers(self, req: urllib.request.Request): def __spoof_req_headers(self, req: urllib.request.Request):
"""Add headers to a request . """Add headers to a request .
@@ -47,8 +69,8 @@ class SWPWebspeiseplanAPI:
) )
req.add_header("Accept-Language", "en-US,en;q=0.9") req.add_header("Accept-Language", "en-US,en;q=0.9")
req.add_header("Connection", "keep-alive") req.add_header("Connection", "keep-alive")
req.add_header("Host", "swp.webspeiseplan.de") req.add_header("Host", self.host)
req.add_header("Referer", "https://swp.webspeiseplan.de/InitialConfig") req.add_header("Referer", f"{self.base_url}/InitialConfig")
req.add_header( req.add_header(
"Sec-Ch-Ua", "Sec-Ch-Ua",
'"Not/A)Brand";v="99", ' '"Not/A)Brand";v="99", '
@@ -77,10 +99,9 @@ class SWPWebspeiseplanAPI:
Returns: Returns:
[type]: [description] [type]: [description]
""" """
url = f"{SWPWebspeiseplanAPI.URL_BASE}/index.php?" + "&".join( query = urllib.parse.urlencode(params)
[f"{k}={v}" for k, v in params.items()] url = f"{self.base_url}/index.php?{query}"
) WebspeiseplanAPI.logger.debug("__parse_model: %s", url)
SWPWebspeiseplanAPI.logger.debug("__parse_model: %s", url)
req = urllib.request.Request(url) req = urllib.request.Request(url)
self.__spoof_req_headers(req) self.__spoof_req_headers(req)
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
@@ -89,7 +110,7 @@ class SWPWebspeiseplanAPI:
def parse_token(self) -> str: def parse_token(self) -> str:
"""Get the token from the proxy server.""" """Get the token from the proxy server."""
req = urllib.request.Request(SWPWebspeiseplanAPI.URL_BASE) req = urllib.request.Request(self.base_url)
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
txt = resp.read().decode("utf-8") txt = resp.read().decode("utf-8")
match = re.findall(r"/main.[0-9a-f]+.js", txt) match = re.findall(r"/main.[0-9a-f]+.js", txt)
@@ -101,15 +122,16 @@ class SWPWebspeiseplanAPI:
# JS chunks with cache-busting filenames # JS chunks with cache-busting filenames
match = "/index.js" match = "/index.js"
SWPWebspeiseplanAPI.logger.debug( WebspeiseplanAPI.logger.debug(
"__parse_token: downloading script %s", match "__parse_token: downloading script %s", match
) )
req = urllib.request.Request(f"{SWPWebspeiseplanAPI.URL_BASE}{match}") script_url = urllib.parse.urljoin(f"{self.base_url}/", match)
req = urllib.request.Request(script_url)
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
txt = resp.read().decode("utf-8") txt = resp.read().decode("utf-8")
proxy_token =\ proxy_token =\
re.findall(r"PROXY_TOKEN:\s*[\"']([0-9a-f]+)[\"']", txt)[0] re.findall(r"PROXY_TOKEN:\s*[\"']([0-9a-f]+)[\"']", txt)[0]
SWPWebspeiseplanAPI.logger.debug( WebspeiseplanAPI.logger.debug(
"__parse_token: PROXY_TOKEN %s", proxy_token "__parse_token: PROXY_TOKEN %s", proxy_token
) )
return proxy_token return proxy_token
@@ -1,15 +1,19 @@
import logging import logging
import re
from datetime import datetime, date from datetime import datetime, date
from stw_potsdam.xml_types.canteen_xml import CanteenMeta, CanteenXML from openmensa_parsers.xml_types.canteen_xml import CanteenMeta, CanteenXML
from stw_potsdam.xml_types.times_xml import CanteenOpenTimespec, TimesXML from openmensa_parsers.xml_types.times_xml import CanteenOpenTimespec, TimesXML
from stw_potsdam.xml_types.meal_xml import MealXML from openmensa_parsers.xml_types.meal_xml import MealXML
class SWPWebspeiseplanParser: EURO_PRICE_PATTERN = re.compile(r"(\d+(?:[,.]\d{1,2})?)\s*€")
"""Class method to parse SWP_Webspeiseplan."""
class WebspeiseplanParser:
"""Parser for Webspeiseplan menu and outlet data."""
def __init__(self) -> None: def __init__(self) -> None:
"""Init SWPWebspeiseplanParser object.""" """Init WebspeiseplanParser object."""
logging.basicConfig() logging.basicConfig()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@@ -56,6 +60,42 @@ class SWPWebspeiseplanParser:
canteen = CanteenXML(canteen_meta, canteen_times) canteen = CanteenXML(canteen_meta, canteen_times)
return canteen return canteen
def _parse_price(self, value):
if value in (None, "", {}):
return None
return float(str(value).replace(",", "."))
def _parse_embedded_prices(
self, name: str, price: dict[str, float | None]
) -> tuple[str, dict[str, float | None]]:
if any(price.values()):
return name, price
matches = EURO_PRICE_PATTERN.findall(name)
if len(matches) < 2:
return name, price
parsed = [self._parse_price(match) for match in matches]
if len(parsed) >= 3:
price = {
"student": parsed[0],
"employee": parsed[1],
"other": parsed[2],
}
elif "Stud" in name and ("Gäste" in name or "Gaeste" in name):
price = {
"student": parsed[0],
"employee": price["employee"],
"other": parsed[1],
}
else:
return name, price
name = EURO_PRICE_PATTERN.sub("", name)
name = re.sub(r"\s*/\s*", " ", name)
name = re.sub(r"\s+", " ", name).strip()
return name, price
def parse_meals( def parse_meals(
self, menu_data, meal_categories self, menu_data, meal_categories
) -> list[tuple[date, str, MealXML]]: ) -> list[tuple[date, str, MealXML]]:
@@ -66,11 +106,20 @@ class SWPWebspeiseplanParser:
info = meal_data["speiseplanAdvancedGericht"] info = meal_data["speiseplanAdvancedGericht"]
additional_info = meal_data["zusatzinformationen"] additional_info = meal_data["zusatzinformationen"]
price = { price = {
"student": additional_info["mitarbeiterpreisDecimal2"], "student": self._parse_price(
"employee": additional_info["price3Decimal2"], additional_info["mitarbeiterpreisDecimal2"]
"other": additional_info["gaestepreisDecimal2"], ),
"employee": self._parse_price(
additional_info["price3Decimal2"]
),
"other": self._parse_price(
additional_info["gaestepreisDecimal2"]
),
} }
meal = MealXML(name=info["gerichtname"], price=price) name, price = self._parse_embedded_prices(
info["gerichtname"], price
)
meal = MealXML(name=name, price=price)
day = datetime.fromisoformat(info["datum"]).date() day = datetime.fromisoformat(info["datum"]).date()
category = meal_categories[info["gerichtkategorieID"]]["name"] category = meal_categories[info["gerichtkategorieID"]]["name"]
meals.append( meals.append(
+54
View File
@@ -0,0 +1,54 @@
from xml.dom import minidom
from dataclasses import dataclass
import logging
from typing import Any
from flask import url_for
from openmensa_parsers.xml_types.openmensa_xml import OpenMensaXML
from openmensa_parsers.config import Canteen
from openmensa_parsers.parsers.base import OpenMensaParser
from openmensa_parsers.parsers.potsdam import PotsdamParser
@dataclass
class Builder:
"""A class method for creating a new OpenMensa Feed."""
VERSION = "2.0.1"
def __init__(
self,
config: dict[str, Canteen],
source_data: Any | None = None,
parser: OpenMensaParser | None = None,
):
"""Initialize the object for the OpenMensa Feed Doc XML."""
logging.basicConfig()
self.logger = logging.getLogger(__name__)
self._xml_data = {}
self.parser = PotsdamParser() if parser is None else parser
raw_data = self.parser.fetch() if source_data is None else source_data
for cname, canteen in self.parser.parse(config, raw_data).items():
feed = self.__create_feed(config[cname])
canteen.add_feed(feed)
self._xml_data[cname] = OpenMensaXML(self.VERSION, canteen)
def __create_feed(self, ntup: Canteen):
return self.parser.create_feed(
ntup,
url_for(
"canteen_xml_feed",
canteen_name=ntup.key,
_external=True,
),
)
def get_xml(self, canteen_name: str):
"""Return a XML string representing the canteen.
Returns:
[type]: [description]
"""
doc = minidom.Document()
xml_element = self._xml_data[canteen_name].xml_element(doc)
doc.appendChild(xml_element)
return doc.toprettyxml(encoding="UTF-8")
@@ -1,9 +1,9 @@
from dataclasses import dataclass from dataclasses import dataclass
from xml.dom import minidom from xml.dom import minidom
from datetime import date from datetime import date
from stw_potsdam.xml_types.times_xml import TimesXML from openmensa_parsers.xml_types.times_xml import TimesXML
from stw_potsdam.xml_types.meal_xml import MealXML from openmensa_parsers.xml_types.meal_xml import MealXML
from stw_potsdam.xml_types.feed_xml import FeedXML from openmensa_parsers.xml_types.feed_xml import FeedXML
@dataclass @dataclass
@@ -1,6 +1,6 @@
from xml.dom import minidom from xml.dom import minidom
from dataclasses import dataclass from dataclasses import dataclass
from stw_potsdam.xml_types.canteen_xml import CanteenXML from openmensa_parsers.xml_types.canteen_xml import CanteenXML
@dataclass @dataclass
+12 -3
View File
@@ -1,7 +1,11 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "om-parser-stw-potsdam-v2" name = "openmensa-parsers"
version = "2.0.1" version = "2.0.1"
description = "OpenMensa parser components for Studentenwerk Potsdam." description = "OpenMensa parser components for multiple canteen data sources."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -16,7 +20,6 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest", "pytest",
"coveralls",
"pytest-cov", "pytest-cov",
"httpretty", "httpretty",
"pycodestyle", "pycodestyle",
@@ -26,3 +29,9 @@ dev = [
"sphinx-autobuild", "sphinx-autobuild",
"sphinx-rtd-theme", "sphinx-rtd-theme",
] ]
[tool.setuptools.packages.find]
include = ["openmensa_parsers*"]
[tool.setuptools.package-data]
openmensa_parsers = ["canteens.ini"]
-51
View File
@@ -1,51 +0,0 @@
-r requirements.txt
alabaster==1.0.0; python_version >= '3.10'
anyio==4.6.2.post1; python_version >= '3.9'
astroid==3.3.5; python_full_version >= '3.9.0'
babel==2.16.0; python_version >= '3.8'
certifi==2024.8.30; python_version >= '3.6'
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
click==8.1.7; python_version >= '3.7'
colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'
coverage[toml]==6.5.0; python_version >= '3.7'
coveralls==3.3.1; python_version >= '3.5'
dill==0.3.9; python_version >= '3.8'
docopt==0.6.2
docutils==0.21.2; python_version >= '3.9'
h11==0.14.0; python_version >= '3.7'
httpretty==1.1.4; python_version >= '3'
idna==3.10; python_version >= '3.6'
imagesize==1.4.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
iniconfig==2.0.0; python_version >= '3.7'
isort==5.13.2; python_full_version >= '3.8.0'
jinja2==3.1.4; python_version >= '3.7'
markupsafe==3.0.2; python_version >= '3.9'
mccabe==0.7.0; python_version >= '3.6'
packaging==24.2; python_version >= '3.8'
platformdirs==4.3.6; python_version >= '3.8'
pluggy==1.5.0; python_version >= '3.8'
pycodestyle==2.12.1; python_version >= '3.8'
pydocstyle==6.3.0; python_version >= '3.6'
pygments==2.18.0; python_version >= '3.8'
pylint==3.3.1; python_full_version >= '3.9.0'
pytest-cov==5.0.0; python_version >= '3.8'
pytest==8.3.3; python_version >= '3.8'
requests==2.32.3; python_version >= '3.8'
sniffio==1.3.1; python_version >= '3.7'
snowballstemmer==2.2.0
sphinx-autobuild==2024.10.3; python_version >= '3.9'
sphinx-rtd-theme==3.0.2; python_version >= '3.8'
sphinx==8.1.3; python_version >= '3.10'
sphinxcontrib-applehelp==2.0.0; python_version >= '3.9'
sphinxcontrib-devhelp==2.0.0; python_version >= '3.9'
sphinxcontrib-htmlhelp==2.1.0; python_version >= '3.9'
sphinxcontrib-jquery==4.1; python_version >= '2.7'
sphinxcontrib-jsmath==1.0.1; python_version >= '3.5'
sphinxcontrib-qthelp==2.0.0; python_version >= '3.9'
sphinxcontrib-serializinghtml==2.0.0; python_version >= '3.9'
starlette==0.41.2; python_version >= '3.8'
tomlkit==0.13.2; python_version >= '3.8'
urllib3==2.2.3; python_version >= '3.8'
uvicorn==0.32.0; python_version >= '3.8'
watchfiles==0.24.0; python_version >= '3.8'
websockets==14.1; python_version >= '3.9'
-15
View File
@@ -1,15 +0,0 @@
blinker==1.9.0; python_version >= '3.9'
cachetools==5.5.0; python_version >= '3.7'
certifi==2024.8.30; python_version >= '3.6'
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
click==8.1.7; python_version >= '3.7'
flask==3.1.0; python_version >= '3.9'
idna==3.10; python_version >= '3.6'
itsdangerous==2.2.0; python_version >= '3.8'
jinja2==3.1.4; python_version >= '3.7'
markupsafe==3.0.2; python_version >= '3.9'
pyopenmensa==0.95.0
requests==2.32.3; python_version >= '3.8'
urllib3==2.2.3; python_version >= '3.8'
uwsgi==2.0.28
werkzeug==3.1.3; python_version >= '3.9'
-69
View File
@@ -1,69 +0,0 @@
from xml.dom import minidom
from dataclasses import dataclass
import logging
from flask import url_for
from stw_potsdam.xml_types.openmensa_xml import OpenMensaXML
from stw_potsdam.swp_webspeiseplan_api import SWPWebspeiseplanAPI
from stw_potsdam.swp_webspeiseplan_parser import SWPWebspeiseplanParser
from stw_potsdam.config import Canteen
from stw_potsdam.xml_types.feed_xml import FeedXML, ScheduleXML
@dataclass
class Builder:
"""A class method for creating a new OpenMensa Feed."""
VERSION = "2.0.1"
def __init__(self, config: dict[str, Canteen]):
"""Initialize the object for the OpenMensa Feed Doc XML."""
logging.basicConfig()
self.logger = logging.getLogger(__name__)
self._xml_data = {}
swp_api = SWPWebspeiseplanAPI()
swp_parser = SWPWebspeiseplanParser()
for cname, ntup in config.items():
if ntup.name not in swp_api.outlets.keys():
self.logger.warning("%s not found in keys", ntup.name)
continue
outlet = swp_api.outlets[ntup.name]
menus = swp_api.menus[ntup.name]
categories = swp_api.meal_categories[ntup.name]
locations = swp_api.locations[ntup.name]
outlet["isPublic"] = locations["isPublic"]
canteen = swp_parser.parse_canteen_meta_times(outlet)
meals = swp_parser.parse_meals(menus, categories)
for kwargs in meals:
canteen.add_meal(**kwargs)
feed = self.__create_feed(ntup)
canteen.add_feed(feed)
self._xml_data[cname] = OpenMensaXML(self.VERSION, canteen)
def __create_feed(self, ntup: Canteen):
schedule = ScheduleXML(
hour="8-14",
retry="30 1",
)
feed = FeedXML(
name="full",
priority=0,
source=SWPWebspeiseplanAPI.URL_BASE,
url=url_for(
"canteen_xml_feed",
canteen_name=ntup.key,
_external=True,
),
schedule=schedule,
)
return feed
def get_xml(self, canteen_name: str):
"""Return a XML string representing the canteen.
Returns:
[type]: [description]
"""
doc = minidom.Document()
xml_element = self._xml_data[canteen_name].xml_element(doc)
doc.appendChild(xml_element)
return doc.toprettyxml(encoding="UTF-8")
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import os import os
import pytest import pytest
from stw_potsdam.config import read_canteen_config from openmensa_parsers.config import read_canteen_config
def _resource_path(filename): def _resource_path(filename):
+91
View File
@@ -0,0 +1,91 @@
# -*- encoding: utf-8 -*-
import pytest
from openmensa_parsers.config import Canteen
from openmensa_parsers.parsers.base import BaseOpenMensaParser, FeedDefinition
from openmensa_parsers.parsers.registry import create_parser, get_parser_class
from openmensa_parsers.parsers.potsdam import PotsdamParser
from openmensa_parsers.views import app
from openmensa_parsers.xml_types.builder import Builder
from openmensa_parsers.xml_types.canteen_xml import CanteenMeta, CanteenXML
from openmensa_parsers.xml_types.times_xml import CanteenOpenTimespec, TimesXML
def _configured_canteen():
return Canteen(
key="demo",
name="Demo Canteen",
street="Demo Street",
city="Demo City",
id="1",
chash="demo",
)
def _canteen_xml():
meta = CanteenMeta(
name="Demo Canteen",
address="Demo Street, 12345 Demo City",
city="Demo City",
phone="+49 331 123456",
email="demo@example.test",
availability="public",
)
times = TimesXML({
day: CanteenOpenTimespec("geschlossen")
for day in TimesXML.VALID_DAYS
})
return CanteenXML(meta, times)
class DemoParser(BaseOpenMensaParser):
id = "demo"
feed = FeedDefinition(source="https://example.test")
def __init__(self):
self.fetched = False
self.parsed_raw_data = None
def fetch(self):
self.fetched = True
return {"source": "fixture"}
def parse(self, _config, raw_data):
self.parsed_raw_data = raw_data
return {"demo": _canteen_xml()}
def test_builder_uses_supplied_parser():
parser = DemoParser()
with app.test_request_context():
builder = Builder({"demo": _configured_canteen()}, parser=parser)
assert parser.fetched
assert parser.parsed_raw_data == {"source": "fixture"}
assert b"https://example.test" in builder.get_xml("demo")
def test_builder_accepts_source_data_fixture():
parser = DemoParser()
with app.test_request_context():
Builder(
{"demo": _configured_canteen()},
source_data={"source": "test-fixture"},
parser=parser,
)
assert not parser.fetched
assert parser.parsed_raw_data == {"source": "test-fixture"}
def test_parser_registry_loads_default_parser():
assert get_parser_class("potsdam") is PotsdamParser
assert isinstance(create_parser("potsdam"), PotsdamParser)
def test_parser_registry_rejects_unknown_parser():
with pytest.raises(KeyError, match="Unknown parser"):
create_parser("unknown")
+2 -2
View File
@@ -4,8 +4,8 @@ import json
import logging import logging
import os import os
import pytest import pytest
from stw_potsdam.config import read_canteen_config from openmensa_parsers.config import read_canteen_config
from stw_potsdam.views import canteen_xml_feed, app from openmensa_parsers.views import canteen_xml_feed, app
# pragma pylint: disable=invalid-name,redefined-outer-name # pragma pylint: disable=invalid-name,redefined-outer-name
+2 -3
View File
@@ -1,7 +1,7 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
import pytest import pytest
from stw_potsdam import views from openmensa_parsers import views
# pytest fixtures are linked via parameter names of test methods # pytest fixtures are linked via parameter names of test methods
# pragma pylint: disable=unused-import,redefined-outer-name,unused-argument # pragma pylint: disable=unused-import,redefined-outer-name,unused-argument
@@ -10,7 +10,6 @@ from tests.stub_api import api_offline, api_online_one_shot
from .response_util import meal_names from .response_util import meal_names
# Long test method names are not 'snake case'! # Long test method names are not 'snake case'!
# See https://github.com/PyCQA/pylint/issues/2047
# The fix has not been ported to Python 2.x. # The fix has not been ported to Python 2.x.
# pylint: disable=invalid-name # pylint: disable=invalid-name
@@ -21,7 +20,7 @@ def test_health_check(client):
assert response.data == b"OK" assert response.data == b"OK"
def test_index(client): def test_index(client, api_online_one_shot):
response = client.get("/").json response = client.get("/").json
canteen_url = response.get("griebnitzsee", None) canteen_url = response.get("griebnitzsee", None)
assert canteen_url, "Known canteen in index response" assert canteen_url, "Known canteen in index response"
+43
View File
@@ -0,0 +1,43 @@
# -*- encoding: utf-8 -*-
import httpretty
import pytest
from openmensa_parsers.webspeiseplan_api import WebspeiseplanAPI
# pytest fixtures are linked via parameter names of test methods
# pragma pylint: disable=unused-import,unused-argument,redefined-outer-name
from tests.stub_api import api_offline
def test_api_init_does_not_fetch(api_offline):
"""Creating the API client does not perform network requests."""
WebspeiseplanAPI("https://menus.example.test")
def test_api_init_requires_valid_base_url():
"""Creating the API client validates the base URL."""
with pytest.raises(ValueError):
WebspeiseplanAPI("menus.example.test")
def test_parse_token_uses_configured_base_url():
"""The API client uses the configured Webspeiseplan host."""
httpretty.enable(allow_net_connect=False)
try:
httpretty.register_uri(
httpretty.GET,
"https://menus.example.test",
body='<script src="/main.abc123.js"></script>',
)
httpretty.register_uri(
httpretty.GET,
"https://menus.example.test/main.abc123.js",
body='PROXY_TOKEN: "0123456789abcdef"',
)
token = WebspeiseplanAPI("https://menus.example.test/").parse_token()
finally:
httpretty.disable()
httpretty.reset()
assert token == "0123456789abcdef"
+60
View File
@@ -0,0 +1,60 @@
# -*- encoding: utf-8 -*-
from openmensa_parsers.webspeiseplan_parser import WebspeiseplanParser
def _menu_item(name):
return [
{
"speiseplanGerichtData": [
{
"speiseplanAdvancedGericht": {
"datum": "2026-05-01T00:00:00",
"gerichtkategorieID": 1,
"gerichtname": name,
},
"zusatzinformationen": {
"mitarbeiterpreisDecimal2": 0,
"price3Decimal2": 0,
"gaestepreisDecimal2": 0,
},
}
]
}
]
def _parse_meal(name):
parser = WebspeiseplanParser()
meals = parser.parse_meals(_menu_item(name), {1: {"name": "Salattheke"}})
return meals[0]["meal"]
def test_parse_salad_bar_three_embedded_prices():
meal = _parse_meal(
"große Schale kleine Schale Relevo Schale 100g "
"1,10 €/ 1,70 €/ 1,90€"
)
assert meal.name == "große Schale kleine Schale Relevo Schale 100g"
assert meal.price == {
"student": 1.10,
"employee": 1.70,
"other": 1.90,
}
def test_parse_salad_bar_student_guest_embedded_prices():
meal = _parse_meal(
"große Schale\nkleine Schale\nRelevo Schale\n"
"100g Stud. 1,00€/ Gäste 1,45€"
)
assert meal.name == (
"große Schale kleine Schale Relevo Schale 100g Stud. Gäste"
)
assert meal.price == {
"student": 1.00,
"employee": 0.0,
"other": 1.45,
}
Generated
+2 -24
View File
@@ -221,20 +221,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" },
] ]
[[package]]
name = "coveralls"
version = "4.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "docopt" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/b3/25862e4610461ca5ffe75cf8d05e830d0b920e023530ab486598ddbe8df8/coveralls-4.0.2.tar.gz", hash = "sha256:7c21ffa2808d3052fa0cfca3842a9f3d21cc8eada02538c192d932199e5f07d4", size = 12408, upload-time = "2025-11-07T19:18:50.54Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/96/3c046a70c27c9be06589aaab99e7e10067177a338a874311347e662aecb1/coveralls-4.0.2-py3-none-any.whl", hash = "sha256:3940f613eac6b3c14d1425741929e1d15f57666f5e7ae0572bbe92357bd6f7ee", size = 13549, upload-time = "2025-11-07T19:18:49.189Z" },
]
[[package]] [[package]]
name = "dill" name = "dill"
version = "0.4.1" version = "0.4.1"
@@ -244,12 +230,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
] ]
[[package]]
name = "docopt"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" }
[[package]] [[package]]
name = "docutils" name = "docutils"
version = "0.22.4" version = "0.22.4"
@@ -421,9 +401,9 @@ wheels = [
] ]
[[package]] [[package]]
name = "om-parser-stw-potsdam-v2" name = "openmensa-parsers"
version = "2.0.1" version = "2.0.1"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cachetools" }, { name = "cachetools" },
{ name = "flask" }, { name = "flask" },
@@ -434,7 +414,6 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "coveralls" },
{ name = "httpretty" }, { name = "httpretty" },
{ name = "pycodestyle" }, { name = "pycodestyle" },
{ name = "pydocstyle" }, { name = "pydocstyle" },
@@ -449,7 +428,6 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "cachetools" }, { name = "cachetools" },
{ name = "coveralls", marker = "extra == 'dev'" },
{ name = "flask" }, { name = "flask" },
{ name = "httpretty", marker = "extra == 'dev'" }, { name = "httpretty", marker = "extra == 'dev'" },
{ name = "pycodestyle", marker = "extra == 'dev'" }, { name = "pycodestyle", marker = "extra == 'dev'" },