From 89677fbeee232243d3976bc6f952972c1793197f Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 18 Nov 2025 19:05:41 +0100 Subject: [PATCH] First attempt --- .gitignore | 1 + hackertours.py | 98 +++ pyproject.toml | 15 + uv.lock | 503 +++++++++++++ voc/LICENSE | 287 ++++++++ voc/README.md | 239 +++++++ voc/__init__.py | 11 + voc/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 593 bytes voc/__pycache__/schedule.cpython-312.pyc | Bin 0 -> 40020 bytes voc/c3data.py | 242 +++++++ voc/event.py | 163 +++++ voc/generic.py | 25 + voc/git.py | 52 ++ voc/logger.py | 50 ++ voc/pretalx.py | 50 ++ voc/rc3hub.py | 130 ++++ voc/room.py | 45 ++ voc/schedule.py | 855 +++++++++++++++++++++++ voc/tools.py | 383 ++++++++++ voc/voctoimport.py | 136 ++++ voc/webcal.py | 57 ++ voc/webcal2.py | 119 ++++ 22 files changed, 3461 insertions(+) create mode 100644 .gitignore create mode 100644 hackertours.py create mode 100644 pyproject.toml create mode 100644 uv.lock create mode 100644 voc/LICENSE create mode 100644 voc/README.md create mode 100644 voc/__init__.py create mode 100644 voc/__pycache__/__init__.cpython-312.pyc create mode 100644 voc/__pycache__/schedule.cpython-312.pyc create mode 100644 voc/c3data.py create mode 100644 voc/event.py create mode 100644 voc/generic.py create mode 100644 voc/git.py create mode 100644 voc/logger.py create mode 100644 voc/pretalx.py create mode 100644 voc/rc3hub.py create mode 100644 voc/room.py create mode 100644 voc/schedule.py create mode 100644 voc/tools.py create mode 100644 voc/voctoimport.py create mode 100644 voc/webcal.py create mode 100644 voc/webcal2.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6c57f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/hackertours.py b/hackertours.py new file mode 100644 index 0000000..fab5181 --- /dev/null +++ b/hackertours.py @@ -0,0 +1,98 @@ +""" +Creates a schedule.json from the Hackertours planning Excel. +""" +import math +import sys +from datetime import datetime, timedelta, timezone + +import pandas +import pytz +from numpy import nan + +import voc.tools +from voc.schedule import Schedule, Event + + +def create_schedule(sheet_file: str, schedule_file: str): + # global voc.tools.VERSION + voc.tools.VERSION = "0.0.1" + + planning_df = pandas.read_excel(sheet_file, sheet_name="Tours") + description_df = pandas.read_excel(sheet_file, sheet_name="Descriptions") + + descriptions = {} + for index, row in description_df.iterrows(): + d = {} + for k in description_df.keys(): + d[k] = description_df[k][index] + descriptions[d['Name']] = d + + start = datetime(year=2025, month=12, day=26, tzinfo=pytz.timezone('Europe/Berlin')) + acronym = "ht" + duration = 4 + + schedule = Schedule.from_template( + title="39C3 Hackertours", + acronym="39c3ht", + year=2025, + month=12, + day=26, + days_count=5, + tz="Europe/Berlin") + # schedule.schedule().version = '1.0' + + # Remove reporting rows + for index, row in planning_df.iterrows(): + name = row['Tour'] + if isinstance(name, float) and math.isnan(name): + planning_df.drop(index=range(index, len(planning_df)), inplace=True) + break + + # Sort to make stable + planning_df.sort_values(by=['Tag', 'Am HT-Desk', 'Tour'], inplace=True) + + # in the Excel, days start at 0; the event here starts on the 26th. Normally, we would convert the 1-based day to a 0-ased offset from the start day, but here, it's 0-based + for index, row in planning_df.iterrows(): + name = row['Tour'] + if isinstance(name, float) and math.isnan(name): + break + if name not in descriptions: + continue + start_time = row['Am HT-Desk'] + event_start = start + timedelta(days=row['Tag']) + event_start = event_start.replace(hour=start_time.hour, minute=start_time.minute) + event_duration = row['Dauer'] + print(f"name={name} day={row['Tag']} start={row['Am HT-Desk']} duration={row['Dauer']}") + guid = voc.tools.gen_uuid('{}-{}-{}'.format(row['Tag'], row['Am HT-Desk'], name)) + + schedule.add_event(Event({ + 'id': index, + 'guid': guid, + # ('logo', None, + 'date': event_start.isoformat(), + 'start': event_start.strftime('%H:%M'), + 'duration': event_duration.strftime("%M:%S"), + 'room': descriptions[name]['Title'], + 'slug': voc.tools.normalise_string(f"{name}-{row['Tag']:0.0f}-{row['Am HT-Desk']}".lower()), + 'url': descriptions[name]['Link'], + 'title': descriptions[name]['Title'], + 'subtitle': None, + 'track': None, + 'type': None, + 'language': row['Sprache'], + 'abstract': descriptions[name]['Abstract'], + 'description': None, + 'persons': [], + 'links': [ + { + 'url': descriptions[name]['Link'], + 'title': descriptions[name]['Title'] + } + ] + })) + + schedule.export(schedule_file) + + +if __name__ == "__main__": + create_schedule(sys.argv[1], sys.argv[2]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3337df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "hackertours-schedule" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.12" +dependencies = [ + "beautifulsoup4>=4.14.2", + "gitpython>=3.1.45", + "icalendar>=6.3.2", + "ics>=0.7.2", + "lxml>=6.0.2", + "openpyxl>=3.1.5", + "pandas>=2.3.3", + "requests>=2.32.5", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2a7dafa --- /dev/null +++ b/uv.lock @@ -0,0 +1,503 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "hackertours-schedule" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "gitpython" }, + { name = "icalendar" }, + { name = "ics" }, + { name = "lxml" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.14.2" }, + { name = "gitpython", specifier = ">=3.1.45" }, + { name = "icalendar", specifier = ">=6.3.2" }, + { name = "ics", specifier = ">=0.7.2" }, + { name = "lxml", specifier = ">=6.0.2" }, + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pandas", specifier = ">=2.3.3" }, + { name = "requests", specifier = ">=2.32.5" }, +] + +[[package]] +name = "icalendar" +version = "6.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422, upload-time = "2025-11-05T12:49:32.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403, upload-time = "2025-11-05T12:49:30.691Z" }, +] + +[[package]] +name = "ics" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "attrs" }, + { name = "python-dateutil" }, + { name = "six" }, + { name = "tatsu" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/2cb7cbe23566140f011da4fec280d4873da4389c8b838bb3e5ce3fc39b16/ics-0.7.2.tar.gz", hash = "sha256:6743539bca10391635249b87d74fcd1094af20b82098bebf7c7521df91209f05", size = 190643, upload-time = "2022-07-06T11:25:41.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/95/e04dea5cf29bdf5005f5aa7ccf0a2f9724b877722d89f8286dc3785a7cdc/ics-0.7.2-py2.py3-none-any.whl", hash = "sha256:5fcf4d29ec6e7dfcb84120abd617bbba632eb77b097722b7df70e48dbcf26103", size = 40086, upload-time = "2022-07-06T11:25:36.536Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, + { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, + { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, + { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, + { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, + { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, + { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, + { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, + { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, + { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "tatsu" +version = "5.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/69/0dd8b975d30637701fde9ff3f8ea52a15fc64317ac9642f4cdeab8a93ad1/tatsu-5.13.2.tar.gz", hash = "sha256:d55aa0bdde2ab5efec73d96f9ce41b8a4aef5cee58eb6e05d1951ad3e03151d9", size = 132236, upload-time = "2025-09-25T22:41:44.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/57/bb1b683d46a78cf48aa40130d4f1ef82f50a73c72a9d9836d6347a3efbef/tatsu-5.13.2-py3-none-any.whl", hash = "sha256:482c6117e92e54fa770664b758f13b1729a01d5c94d7ccbc270d5ffa1dfe2d8a", size = 80340, upload-time = "2025-09-25T22:41:42.661Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] diff --git a/voc/LICENSE b/voc/LICENSE new file mode 100644 index 0000000..c29ce2f --- /dev/null +++ b/voc/LICENSE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. \ No newline at end of file diff --git a/voc/README.md b/voc/README.md new file mode 100644 index 0000000..00281a9 --- /dev/null +++ b/voc/README.md @@ -0,0 +1,239 @@ +# C3VOC Schedule Tools + +[![PyPI version](https://badge.fury.io/py/c3voc-schedule-tools.svg)](https://badge.fury.io/py/c3voc-schedule-tools) +[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) + +A Python library for generating, converting, and validating [schedule files](https://c3voc.de/wiki/schedule) for conferences and events. + +Originally developed for the Chaos Computer Club events (C3), this library supports multiple schedule formats and conference management systems including [pretalx](https://github.com/pretalx/pretalx), [frab](https://frab.github.io/frab/). +## Features + +- **Integration**: Direct integration with pretalx, frab, and other conference planning systems +- **Schedule Validation**: Built-in validation against schedule XML schema +- **Flexible Data Sources**: Support for web APIs, local files, and custom data sources +- **Multiple Converters**: Built-in converters for various data sources and formats + +## Installation + +```bash +pip install c3voc-schedule-tools +``` + +## Quick Start + +### Basic Schedule Creation + +```python +from voc import Schedule, Event, Room + +# Create a new schedule +schedule = Schedule.from_template( + name="My Conference 2024", + conference_title="My Conference", + conference_acronym="MC24", + start_day=25, + days_count=3, + timezone="Europe/Berlin" +) + +# Add rooms, generate your own global unique ids e.g. via `uuidgen` +schedule.add_rooms([ + {"name": "Main Hall", "guid": "67D04C40-B35A-496A-A31C-C0F3FF63DAB7"}, + {"name": "Workshop Room", "guid": "5564FBA9-DBB5-4B6B-A0F0-CCF6C9F1EBD7"} +]) + +# Add an event +event = Event({ + "id": "event-1", + "title": "Opening Keynote", + "abstract": "Welcome to the conference", + "date": "2024-12-25T10:00:00+01:00", + "duration": "01:00", + "room": "Main Hall", + "track": "Keynotes", + "type": "lecture", + "language": "en", + "persons": [{"public_name": "Jane Doe"}] +}) +schedule.add_event(event) + +# Export to JSON +schedule.export('schedule.json') +``` + +### Loading from Pretalx + +```python +from voc import PretalxConference, Schedule + +# Load conference data from pretalx +conference = PretalxConference( + url="https://pretalx.example.com/event/my-conference/", + data={"name": "My Conference"} +) + +# Get the schedule +schedule = conference.schedule() + +# Export to different formats +schedule.export('schedule.json') +schedule.export('schedule.xml') +``` + +### Working with Existing Schedules + +```python +from voc import Schedule + +# Load from URL +schedule = Schedule.from_url("https://example.com/schedule.json") + +# Load from file +schedule = Schedule.from_file("schedule.json") + +# Filter events by track +track_events = schedule.events(filter=lambda e: e.get('track') == 'Security') + +# Get all rooms +rooms = schedule.rooms() + +# Get events for a specific day +day_1_events = schedule.day(1).events() +``` + + +## API Reference + +### Core Classes + +#### Schedule + +The main schedule container that holds conference metadata, days, rooms, and events. + +**Key Methods:** + +- `Schedule.from_url(url)` - Load schedule from URL +- `Schedule.from_file(path)` - Load schedule from file +- `Schedule.from_template(...)` - Create from template +- `add_event(event)` - Add an event to the schedule +- `add_rooms(rooms)` - Add rooms to the schedule +- `export(filename)` - Export to file +- `validate()` - Validate against XML schema + + +#### Event + +Represents a single conference event/talk. + +**Properties:** + +- `guid` - Global unique event identifier +- `id` - Local event identifier, deprecated +- `title` - Event title +- `abstract` - Event description +- `date` - Start date/time +- `duration` - Event duration +- `room` - Room name +- `track` - Track/category +- `persons` - List of speakers + + +#### Room + +Represents a conference room / lecture hall / etc. + +**Properties:** + +- `name` - Room name +- `guid` - Global unique room identifier + + +### Conference Planning Systems + +#### PretalxConference + +Integration with pretalx conference management system. + +```python +conference = PretalxConference( + url="https://pretalx.example.com/event/", + data={"name": "Conference Name"} +) +schedule = conference.schedule() +``` + +#### GenericConference + +Base class for generic conference data sources. + +```python +conference = GenericConference( + url="https://example.com/schedule.json", + data={"name": "Conference Name"} +) +``` + +#### WebcalConference + +Import from iCal/webcal sources. + +```python +from voc import WebcalConference + +conference = WebcalConference(url="https://example.com/events.ics") +schedule = conference.schedule(template_schedule) +``` + + +## Supported Formats + +### Input Formats + +- **JSON**: schedule.json format +- **iCal**: RFC 5545 iCalendar format +- **Pretalx API**: Direct API integration +- **CSV**: Custom CSV formats (see examples in [parent folder](https://github.com/voc/schedule/blob/master/csv2schedule_deu.py)) + +### Output Formats + +- **JSON**: C3VOC schedule.json +- **XML**: CCC / Frab schedule XML aka [vnd.c3voc.schedule+xml](https://www.iana.org/assignments/media-types/application/vnd.c3voc.schedule+xml) +- **iCal**: RFC 5545 format (TODO?) + + +## Configuration + +### Environment Variables + +- `PRETALX_TOKEN` - API token for pretalx integration +- `C3DATA_API_URL` - C3data API endpoint +- `C3DATA_TOKEN` - C3data authentication token + +### Validation + +The library includes built-in validation against the schedule XML schema: + +```python +# Validate a schedule +try: + schedule.validate() + print("Schedule is valid") +except ScheduleException as e: + print(f"Validation error: {e}") +``` + +## Examples + +TBD, see parent folder + +## License + +This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details. + +## Links + +- [Documentation](https://c3voc.de/wiki/schedule) +- [PyPI Package](https://pypi.org/project/c3voc-schedule-tools/) +- [Source Code](https://github.com/voc/schedule) +- [Issue Tracker](https://github.com/voc/schedule/issues) diff --git a/voc/__init__.py b/voc/__init__.py new file mode 100644 index 0000000..29cf2a7 --- /dev/null +++ b/voc/__init__.py @@ -0,0 +1,11 @@ +# flake8: noqa + +from .schedule import Schedule, ScheduleDay, ScheduleEncoder, ScheduleException +from .event import Event +from .room import Room +from .generic import GenericConference +from .pretalx import PretalxConference +from .webcal import WebcalConference +from .webcal2 import WebcalConference2 + +from .logger import Logger \ No newline at end of file diff --git a/voc/__pycache__/__init__.cpython-312.pyc b/voc/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..054a0e2618d6864f9a87f07e8e2669b492df3e71 GIT binary patch literal 593 zcmX@j%ge>Uz`&3ZB#`OG$iVOz#DQTZDC2Vh0|Uc!h7^Vr#vF!R#wf;IrYNRd<|yV| zmM9iRkT_EgYc5+98<@?U!=B3##Q|os1Gct^mw|y{CBtV>Wc_l`4=qkDD%LM9NzyORFUro$OV@`vNk1boIXkteB)_z% zSQjo>mY=L2AD@|*SrQ+wS5SG2!zMQ$6xen}k_-$CpeQI-W?*3Wz|6?V_??Y`F@W(h dga2IyiMtG9cNx^cNaZer+A{{JB5?)=1^|G-rp^EW literal 0 HcmV?d00001 diff --git a/voc/__pycache__/schedule.cpython-312.pyc b/voc/__pycache__/schedule.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13ef9e69c259e2199bfc7849bfe42ca8106cad07 GIT binary patch literal 40020 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 o#qchR 0 and isinstance(r["links"][0], str): + r["links"] = [{"url": url, "title": url} for url in r["links"]] + return r + + def voctoimport(self): + r = dict(self._event.items()) + r["talkid"] = self._event["id"] + del r["id"] + del r["type"] + del r["start"] + del r["persons"] + del r["logo"] + del r["subtitle"] + if "recording_license" in r: + del r["recording_license"] + if "recording" in r: + del r["recording"] + if "do_not_record" in r: + del r["do_not_record"] + if "video_download_url" in r: + del r["video_download_url"] + if "answers" in r: + del r["answers"] + if "links" in r: + del r["links"] + if "attachments" in r: + del r["attachments"] + return r + + # export all attributes which are not part of rC3 core event model + def meta(self): + r = OrderedDict(self._event.items()) + # r['local_id'] = self._event['id'] + # del r["id"] + del r["guid"] + del r["slug"] + del r["room"] + del r["start"] + del r["date"] + del r["duration"] + del r["track_id"] + del r["track"] + # del r['persons'] + # if 'answers' in r: + # del r['answers'] + # fix wrong formatted links + if len(r["links"]) > 0 and isinstance(r["links"][0], str): + r["links"] = [{"url": url, "title": url} for url in r["links"]] + return r + + def __str__(self): + return json.dumps(self._event, indent=2) + + def export(self, prefix, suffix=""): + with open("{}{}{}.json".format(prefix, self._event["guid"], suffix), "w") as fp: + json.dump(self._event, fp, indent=2) diff --git a/voc/generic.py b/voc/generic.py new file mode 100644 index 0000000..ea3463b --- /dev/null +++ b/voc/generic.py @@ -0,0 +1,25 @@ +from voc.event import EventSourceInterface +from .schedule import Schedule, ScheduleException +from urllib.parse import urlparse + + +class GenericConference(dict, EventSourceInterface): + schedule_url = None + options = {} + timeout = 10 + + def __init__(self, url, data, options={}): + self.origin_system = urlparse(url).netloc + self.schedule_url = url + self.options = options + self['url'] = url + dict.__init__(self, data) + + def __str__(self): + return self['name'] + + def schedule(self, *args) -> Schedule: + if not self.schedule_url or self.schedule_url == 'TBD': + raise ScheduleException(' has no schedule url yet – ignoring') + + return Schedule.from_url(self.schedule_url, self.timeout) diff --git a/voc/git.py b/voc/git.py new file mode 100644 index 0000000..f33168b --- /dev/null +++ b/voc/git.py @@ -0,0 +1,52 @@ + +import argparse +import json +import os +from git import Repo +from voc.c3data import C3data +from voc.event import Event +from voc.schedule import Schedule, ScheduleEncoder +from voc.tools import ( + commit_changes_if_something_relevant_changed, + git, +) + +def export_event_files(schedule: Schedule, options: argparse.Namespace, local = False): + # to get proper a state, we first have to remove all event files from the previous run + if not local or options.git: + git("rm events/* >/dev/null") + os.makedirs('events', exist_ok=True) + + # write separate file for each event, to get better git diffs + # TODO: use Event.export() + def export_event(event: Event): + origin_system = None + if isinstance(event, Event) and event.origin: + origin_system = event.origin.origin_system + + with open("events/{}.json".format(event["guid"]), "w") as fp: + json.dump( + { + **event, + "room_id": schedule._room_ids.get(event["room"], None), + "origin": origin_system or None, + }, + fp, + indent=2, + cls=ScheduleEncoder, + ) + + schedule.foreach_event(export_event) + + +def postprocessing(schedule: Schedule, options: argparse.Namespace, local = False, targets = []): + if not local or options.git: + commit_changes_if_something_relevant_changed(schedule) + # Attention: This method exits the script, if nothing relevant changed + # TODO: make this fact more obvious or refactor code + + if not local and "c3data" in targets: + print("\n== Updating c3data via API…") + + c3data = C3data(schedule) + c3data.process_changed_events(Repo('.'), options) \ No newline at end of file diff --git a/voc/logger.py b/voc/logger.py new file mode 100644 index 0000000..82b5e18 --- /dev/null +++ b/voc/logger.py @@ -0,0 +1,50 @@ +import logging +from logging import info, debug, warn, error, critical # noqa +import argparse + +__all__ = [info, debug, warn, error, critical] + +class Logger(logging.Logger): + def __init__(self, name, args=None, level='INFO'): + logging.Logger.__init__(self, name, level) + # log = logging.getLogger(name) + + if False and args is None: + parser = argparse.ArgumentParser() + parser.add_argument('--quiet', action='store_true') + parser.add_argument('--debug', action='store_true') + parser.add_argument('--verbose', '-v', action='store_true') + args = parser.parse_args() + + if args: + configure_logging(args) + + + + +def configure_logging(args): + verbosity = (args.verbose or args.debug or 0) - (args.quiet or 0) + if verbosity <= -2: + level = logging.CRITICAL + elif verbosity == -1: + level = logging.ERROR + elif verbosity == 0: + level = logging.WARNING + elif verbosity == 1: + level = logging.INFO + elif verbosity >= 2: + level = logging.DEBUG + + # fancy colors + logging.addLevelName(logging.CRITICAL, '\033[1;41m%s\033[1;0m' % logging.getLevelName(logging.CRITICAL)) + logging.addLevelName(logging.ERROR, '\033[1;31m%s\033[1;0m' % logging.getLevelName(logging.ERROR)) + logging.addLevelName(logging.WARNING, '\033[1;33m%s\033[1;0m' % logging.getLevelName(logging.WARNING)) + logging.addLevelName(logging.INFO, '\033[1;32m%s\033[1;0m' % logging.getLevelName(logging.INFO)) + logging.addLevelName(logging.DEBUG, '\033[1;34m%s\033[1;0m' % logging.getLevelName(logging.DEBUG)) + + if args.debug: + log_format = '%(asctime)s - %(name)s - %(levelname)s {%(filename)s:%(lineno)d} %(message)s' + else: + log_format = '%(asctime)s - %(levelname)s - %(message)s' + + #logging.basicConfig(filename=args.logfile, level=level, format=log_format) diff --git a/voc/pretalx.py b/voc/pretalx.py new file mode 100644 index 0000000..a1a5c6b --- /dev/null +++ b/voc/pretalx.py @@ -0,0 +1,50 @@ +from os import path, getenv +import requests +from urllib.parse import urlparse + +from voc import GenericConference, logger + +headers = {'Authorization': 'Token ' + getenv('PRETALX_TOKEN', ''), 'Content-Type': 'application/json'} + + +class PretalxConference(GenericConference): + slug = None + api_url = None + + def __init__(self, url, data, options={}): + GenericConference.__init__(self, url, data, options) + + if url and url != 'TBD': + self.schedule_url = path.join(url, "schedule/export/schedule.json") + r = urlparse(url) + self.slug = data.get('slug', path.basename(r.path)) + + # /api/events/hip-berlin-2022 + self.api_url = path.join(f"{r.scheme}://{r.netloc}{path.dirname(r.path)}", "api/events", self.slug) + + try: + # load additional metadata via pretalx REST API + self['meta'] = self.meta() + self['rooms'] = self.rooms() + except Exception as e: + logger.warn(e) + pass + + def meta(self): + return requests.get(self.api_url, timeout=self.timeout) \ + .json() + + def rooms(self): + return requests.get(self.api_url + '/rooms', timeout=self.timeout, headers=headers if self.origin_system == 'pretalx.c3voc.de' else {'Content-Type': 'application/json'}) \ + .json() \ + .get('results') + + def latest_schedule(self): + return requests.get(self.api_url + '/schedules/latest/', timeout=self.timeout) \ + .json() + # Custom pretalx schedule format + + # def tracks(self): + # return requests.get(self.api_url + '/tracks', timeout=1, headers=headers) if self.origin_system == 'pretalx.c3voc.de' else {} \ + # .json() \ + # .get('results') diff --git a/voc/rc3hub.py b/voc/rc3hub.py new file mode 100644 index 0000000..71d1582 --- /dev/null +++ b/voc/rc3hub.py @@ -0,0 +1,130 @@ +from os import getenv +import json +import requests + +try: + from .schedule import Schedule +except ImportError: + from schedule import Schedule + +url = getenv('HUB_URL', 'https://api-test.rc3.cccv.de/api/c/rc3/') +conference_id = "17391cf3-fc95-4294-bc34-b8371c6d89b3" # rc3 test + +headers = { + 'Authorization': 'Token ' + getenv('HUB_TOKEN', 'XXXX'), + 'Accept': 'application/json' +} + + +def get(path): + print('GET ' + url + path) + r = requests.get(url + path, headers=headers) + print(r.status_code) + return r.json() + + +def post_event(event): + print('POST {}event/{}/schedule'.format(url, event['guid'])) + r = requests.post( + '{}event/{}/schedule'.format(url, event['guid']), + json=event, + headers=headers + ) + print(r.status_code) + + if r.status_code != 201: + print(json.dumps(event, indent=2)) + raise Exception(r.json()['error']) + return r + + +def upsert_event(event): + if event['track']: + if not(event['track'] in tracks): + print('WARNING: Track {} does not exist'.format(event['track'])) + event['track'] = None + + # Workaround for bug in hub: remove empty room_id from dict + if 'room_id' in event and not(event['room_id']) and 'room' in event: + del event['room_id'] + + post_event(event) + + +def depublish_event(event_guid): + post_event({ + 'guid': event_guid.event, + 'public': False + }) + + +skip = False +tracks = [] + + +def init(channels): + global tracks + + tracks = {x['name']: x['id'] for x in get('tracks')} + + +def push_schedule(schedule): + channel_room_ids = {x['schedule_room']: x['room_guid'] for x in channels} + rooms = get('rooms') + room_ids = {x['name']: x['id'] for x in rooms} + hub_room_names = {x['id']: x['name'] for x in rooms} + + print(tracks) + + def process(event): + global skip + if skip: + if event['guid'] == skip: + skip = False + return + + try: + if event['room'] in channel_room_ids: + event['room_id'] = channel_room_ids.get(event['room']) + del event['room'] + elif not(event['room'] in room_ids): + if event['room'] in channel_room_ids: + try: + event['room'] = hub_room_names[channel_room_ids[event['room']]] + except Exception as e: + print(json.dumps(event, indent=2)) + print(e.message) + else: + print('ERROR: Room {} does not exist'.format(event['room'])) + return + upsert_event(event) + + except Exception as e: + print(json.dumps(event, indent=2)) + print(event['guid']) + print(e) + + schedule.foreach_event(process) + + +if __name__ == '__main__': + import optparse + parser = optparse.OptionParser() + # , doc="Skips all events till event with guid X is found.") + parser.add_option('--skip', action="store", dest="skip", default=False) + + options, args = parser.parse_args() + skip = options.skip + + channels = requests \ + .get('https://c3voc.de/wiki/lib/exe/graphql2.php?query={channels{nodes{schedule_room,room_guid}}}') \ + .json()['data']['channels']['nodes'] + + init(channels) + + schedule = Schedule.from_url('https://data.c3voc.de/rC3/everything.schedule.json') + # schedule = Schedule.from_url('https://data.c3voc.de/rC3/channels.schedule.json') + # schedule = Schedule.from_file('rC3/channels.schedule.json') + + push_schedule(schedule) + print('done') diff --git a/voc/room.py b/voc/room.py new file mode 100644 index 0000000..02cd44c --- /dev/null +++ b/voc/room.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass, fields + +try: + from voc.event import Schedule + from voc.tools import gen_uuid, normalise_string +except: + from event import Schedule + from tools import gen_uuid, normalise_string + +@dataclass +class Room: + guid: str = None + name: str = None + stream: str = None + description: str = None + capacity: int = None + location: str = None + + _parent: Schedule = None + + @classmethod + def from_dict(cls, data: dict): + assert isinstance(data, dict), 'Data must be a dictionary.' + + fieldSet = {f.name for f in fields(cls) if f.init} + filteredData = {k: v for k, v in data.items() if k in fieldSet} + + return cls(**filteredData) + + def graphql(self): + return { + 'name': self.name, + 'guid': self.guid or gen_uuid(self.name), + 'description': self.description, + # 'stream_id': room.stream, + 'slug': normalise_string(self.name.lower()), + 'meta': {'location': self.location}, + } + + # @name.setter + def update_name(self, new_name: str, update_parent=True): + if self._parent and update_parent: + self._parent.rename_rooms({self.name: new_name}) + + self.name = new_name diff --git a/voc/schedule.py b/voc/schedule.py new file mode 100644 index 0000000..aa9bbbd --- /dev/null +++ b/voc/schedule.py @@ -0,0 +1,855 @@ +import sys +import os +import re +import json +import copy +import requests +import pytz +import dateutil.parser +from collections import OrderedDict +from typing import Callable, Dict, List, Union +from datetime import datetime, timedelta +from urllib.parse import urlparse +from lxml import etree as ET + +try: + import voc.tools as tools + from voc.event import Event, EventSourceInterface + from voc.room import Room + from voc.logger import Logger +except ImportError: + import tools + from event import Event, EventSourceInterface + from room import Room + from logger import Logger + + +log = Logger(__name__) + +# validator = f"{sys.path[0]}/validator/xsd/validate_schedule_xml.sh" +validator = f"xmllint --noout --schema {sys.path[0]}/validator/xsd/schedule.xml.xsd" +# validator = f"xmllint --noout --schema {sys.path[0]}/validator/xsd/schedule-without-person.xml.xsd" +validator_filter = "" + + +def set_validator_filter(filter): + global validator_filter + validator_filter += " | awk '" + " && ".join(["!/" + x + "/" for x in filter]) + "'" + + +class ScheduleException(Exception): + pass + + +class ScheduleDay(dict): + start: datetime = None + end: datetime = None + + def __init__( + self, i=None, year=None, month=12, day=None, tz=None, dt=None, json=None + ): + if i is not None and dt: + self.start = dt + self.end = dt + timedelta(hours=23) # conference day lasts 23 hours + + dict.__init__(self, { + "index": i + 1, + "date": dt.strftime("%Y-%m-%d"), + "day_start": self.start.isoformat(), + "day_end": self.start.isoformat(), + "rooms": {}, + }) + return + elif json: + dict.__init__(self, json) + elif i is not None and (day or (year and day)): + dict.__init__(self, { + "index": i + 1, + "date": "{}-{:02d}-{:02d}".format(year, month, day), + "day_start": datetime(year, month, day, 6, 00, tzinfo=tz).isoformat(), + "day_end": datetime(year, month, day + 1, 6, 00, tzinfo=tz).isoformat(), + "rooms": {}, + }) + else: + raise Exception("Either give JSON xor i, year, month, day") + + self.start = dateutil.parser.parse(self["day_start"]) + self.end = dateutil.parser.parse(self["day_end"]) + + def json(self): + return self + + +class Schedule(dict): + """Schedule class with import and export methods""" + _tz = None + _days: list[ScheduleDay] = [] + _room_ids = {} + origin_url = None + origin_system = None + stats = None + generator = None + + def __init__(self, name: str = None, json=None, version: str = None, conference=None, start_hour=9): + if json: + dict.__init__(self, json["schedule"]) + elif conference: + dict.__init__(self, { + "version": version, + "conference": conference + }) + + if "days" in self["conference"]: + self._generate_stats("start" not in self["conference"]) + + if "start" not in self["conference"]: + self["conference"]["start"] = self.stats.first_event.start.isoformat() + if "end" not in self["conference"]: + self["conference"]["end"] = self.stats.last_event.end.isoformat() + + if "rooms" not in self["conference"]: + # looks like we have an old style schedule json, + # so let's construct room map from the scheduling data + room_names = {} + for day in self["conference"].get("days", []): + # TODO: why are don't we use a Set? + room_names.update([(k, None) for k in day["rooms"].keys()]) + self["conference"]["rooms"] = [{"name": name} for name in room_names] + + if "days" not in self["conference"] or len(self["conference"]["days"]) == 0: + tz = self.tz() + date = tz.localize(self.conference_start()).replace(hour=start_hour) + days = [] + for i in range(self.conference("daysCount")): + days.append(ScheduleDay(i, dt=date)) + date += timedelta(hours=24) + self["conference"]["days"] = days + + @classmethod + def from_url(cls, url, timeout=10): + log.info("Requesting " + url) + schedule_r = requests.get(url, timeout=timeout) + + if schedule_r.ok is False: + schedule_r.raise_for_status() + raise Exception( + " Request failed, HTTP {0}.".format(schedule_r.status_code) + ) + + data = schedule_r.json() + + # add sourounding schedule obj for inproperly formated schedule.json's + if "schedule" not in data and "conference" in data: + data = {"schedule": data} + # move days into conference obj for inproperly formated schedule.json's + if "days" in data['schedule']: + data['schedule']['conference']['days'] = data['schedule'].pop("days") + print(json.dumps(data, indent=2)) + + if "version" not in data["schedule"]: + data["schedule"]["version"] = "" + + schedule = Schedule(json=data) + schedule.origin_url = url + schedule.origin_system = urlparse(url).netloc + return schedule + + @classmethod + def from_file(cls, name): + with open(name, "r") as fp: + schedule = tools.parse_json(fp.read()) + return Schedule(json=schedule) + + @classmethod + def from_template( + cls, title, acronym, year, month, day, days_count=1, tz="Europe/Amsterdam" + ): + schedule = Schedule( + version=datetime.now().strftime("%Y:%m-%d %H:%M"), + conference={ + "acronym": acronym.lower(), + "title": title, + "start": "{}-{:02d}-{:02d}".format(year, month, day), + "end": "{}-{:02d}-{:02d}".format(year, month, day + days_count - 1), + "daysCount": days_count, + "timeslot_duration": "00:15", + "time_zone_name": tz, + }, + ) + tzinfo = pytz.timezone(tz) + days = schedule["conference"]["days"] + for i in range(days_count): + d = ScheduleDay(i, year, month, day + i, tz=tzinfo) + days.append(d) + + return schedule + + @classmethod + def from_dict(cls, template, start_hour=9): + schedule = Schedule(json=template) + + return schedule + + @classmethod + def from_XC3_template(cls, name, congress_nr, start_day, days_count): + year = str(1983 + congress_nr) + + schedule = Schedule( + version=datetime.now().strftime("%Y-%m-%d %H:%M"), + conference={ + "acronym": f"{congress_nr}C3" + ("-" + name.lower() if name else ""), + "title": f"{congress_nr}. Chaos Communication Congress" + (" - " + name if name else ""), + "start": "{}-12-{}".format(year, start_day), + "end": "{}-12-{}".format(year, start_day + days_count - 1), + "daysCount": days_count, + "timeslot_duration": "00:15", + "time_zone_name": "Europe/Amsterdam", + }, + ) + + return schedule + + @classmethod + def empty_copy_of(cls, parent_schedule: 'Schedule', name: str, start_hour=None): + schedule = Schedule( + version=datetime.now().strftime("%Y:%m-%d %H:%M"), + conference=copy.deepcopy(parent_schedule.conference()), + ) + schedule["conference"]["title"] += " - " + name + + for day in schedule["conference"]["days"]: + if start_hour is not None: + start = dateutil.parser.parse(day["day_start"]).replace(hour=start_hour) + day["day_start"] = start.isoformat() + day["rooms"] = [] + + return schedule + + def reset_generator(self): + self.generator = tools.generator_info() + + # TODO: test if this method still works after refactoring of Schedule class to dict child + def copy(self, name=None): + schedule = copy.deepcopy(self) + if name: + schedule["conference"]["title"] += f" - {name}" + return Schedule(json={"schedule": schedule}) + + def version(self): + return self["version"] + + def tz(self): + if not self._tz: + self._tz = pytz.timezone(self.conference("time_zone_name")) + return self._tz + + def conference(self, key=None, filter: Callable = None, fallback=None): + if key: + if filter: + return next((item for item in self["conference"][key] if filter(item)), fallback) + + return self["conference"].get(key, fallback) + else: + return self["conference"] + + def conference_start(self): + return dateutil.parser.parse(self.conference("start").split("T")[0]) + + def days(self): + # TODO return _days object list instead of raw dict/json? + return self["conference"]["days"] + + def day(self, day: int): + return self.days()[day - 1] + + def room(self, name=None, guid=None): + if guid: + return self.conference('rooms', lambda x: x['guid'] == guid, {'name': name, 'guid': guid}) + if name: + return self.conference('rooms', lambda x: x['name'] == name, {'name': name, 'guid': guid}) + + raise Exception('Either name or guid has to be provided') + + def rooms(self, mode='names'): + if mode == 'names': + return [room['name'] for room in self.conference('rooms')] + elif mode == 'obj': + return [Room.from_dict(r) for r in self.conference('rooms')] + else: + return self.conference('rooms') + + def add_rooms(self, rooms: list, context: EventSourceInterface = {}): + if rooms: + for x in rooms: + self.add_room(x, context) + + def rename_rooms(self, replacements: Dict[str, str|Room]): + + name_replacements = {} + + for old_name_or_guid, new_room in replacements.items(): + new_name = new_room if isinstance(new_room, str) else new_room.name + + r = self.room(name=old_name_or_guid) or self.room(guid=old_name_or_guid) + if r['name'] != new_name: + name_replacements[r['name']] = new_name + r['name'] = new_name + if isinstance(new_room, Room) and new_room.guid: + r['guid'] = new_room.guid + self._room_ids[new_name] = new_room.guid + elif r.get('guid'): + self._room_ids[new_name] = r['guid'] + + for day in self['conference']['days']: + for room_key, events in list(day['rooms'].items()): + new_room = replacements.get(room_key, room_key) + new_name = new_room if isinstance(new_room, str) else new_room.name + + day['rooms'][new_name] = day['rooms'].pop(room_key) + if room_key != new_name: + for event in events: + event['room'] = new_name + + def add_room(self, room: Union[str, dict], context: EventSourceInterface = {}): + # if rooms is str, use the old behaviour – for backwords compability + if type(room) is str: + for day in self.days(): + if room not in day["rooms"]: + day["rooms"][room] = list() + # otherwise add new room dict to confernce + elif "name" in room: + if room["name"] in self._room_ids and self._room_ids[room["name"]] == room.get('guid'): + # we know this room already, so return early + return + + if 'location' in context: + room['location'] = context['location'] + + self.conference("rooms").append(room) + self._room_ids[room["name"]] = room.get("guid") + self.add_room(room["name"]) + + def room_exists(self, day: int, name: str): + return name in self.day(day)["rooms"] + + def add_room_on_day(self, day: int, name: str): + self.day(day)["rooms"][name] = list() + + def add_room_with_events(self, day: int, target_room, data, origin=None): + if not data or len(data) == 0: + return + + # log.debug(' adding room {} to day {} with {} events'.format(target_room, day, len(data))) + target_day_rooms = self.day(day)["rooms"] + + if self.room_exists(day, target_room): + target_day_rooms[target_room] += data + else: + target_day_rooms[target_room] = data + + + # TODO this method should work woth both room key and room guid, + # but currently it only works with room name + def remove_room(self, room_key: str): + # if room key is a name, remove it directly from the room list + if room_key in self._room_ids: + del self._room_ids[room_key] + + obj = self.room(name=room_key) + self["conference"]["rooms"].remove(obj) + + if room_key in self._room_ids: + del self._room_ids[room_key] + + for day in self["conference"]["days"]: + if room_key in day["rooms"]: + del day["rooms"][room_key] + + def event(self, guid: str) -> Event: + for day in self["conference"]["days"]: + for room in day["rooms"]: + for event in day["rooms"][room]: + if event['guid'] == guid: + if isinstance(event, Event): + return event + else: + return Event(event) + + def add_event(self, event: Event, options=None): + day = self.get_day_from_time(event.start) + if event.get("slug") is None: + event["slug"] = "{acronym}-{id}-{name}".format( + acronym=self.conference()["acronym"], + id=event["id"], + name=tools.normalise_string(event["title"]), + ) + + if not self.room_exists(day, event["room"]): + self.add_room_on_day(day, event["room"]) + + self.days()[day - 1]["rooms"][event["room"]].append(event) + + def foreach_event(self, func, *args): + out = [] + for day in self["conference"]["days"]: + for room in day["rooms"]: + for event in day["rooms"][room]: + result = func(event if isinstance(event, Event) else Event(event), *args) + if result: + out.append(result) + return out + + def foreach_event_raw(self, func, *args): + out = [] + for day in self["conference"]["days"]: + for room in day["rooms"]: + for event in day["rooms"][room]: + result = func(event, *args) + if result: + out.append(result) + + return out + + def foreach_day_room(self, func): + out = [] + for day in self["conference"]["days"]: + for room in day["rooms"]: + result = func(day["rooms"][room]) + if result: + out.append(result) + + return out + + def _generate_stats(self, enable_time_stats=False, verbose=False): + class ScheduleStats: + min_id = None + max_id = None + person_min_id = None + person_max_id = None + events_count = 0 + first_event: Event = None + last_event: Event = None + + self.stats = ScheduleStats() + + def calc_stats(event: Event): + self.stats.events_count += 1 + + id = int(event["id"]) + if self.stats.min_id is None or id < self.stats.min_id: + self.stats.min_id = id + if self.stats.max_id is None or id > self.stats.max_id: + self.stats.max_id = id + + if self.stats.first_event is None or event.start < self.stats.first_event.start: + self.stats.first_event = event + if self.stats.last_event is None or event.start < self.stats.last_event.start: + self.stats.last_event = event + + for person in event.get("persons", []): + if "id" in person and (isinstance(person["id"], int) or person["id"].isnumeric()): + if ( + self.stats.person_min_id is None + or int(person["id"]) < self.stats.person_min_id + ): + self.stats.person_min_id = int(person["id"]) + if ( + self.stats.person_max_id is None + or int(person["id"]) > self.stats.person_max_id + ): + self.stats.person_max_id = int(person["id"]) + + self.foreach_event(calc_stats) + + if verbose: + print(f" from {self['conference']['start']} to {self['conference']['end']}") + print( " contains {events_count} events, with local ids from {min_id} to {max_id}".format(**self.stats.__dict__)) # noqa + print( " local person ids from {person_min_id} to {person_max_id}".format(**self.stats.__dict__)) # noqa + print(f" rooms: {', '.join(self.rooms())}") + + + def get_day_from_time(self, start_time): + for i in range(self.conference("daysCount")): + day = self.day(i + 1) + if day.start <= start_time < day.end: + # print(f"Day {day.index}: day.start {day.start} <= start_time {start_time} < day.end {day.end}") + #print(f"Day {day['index']}: day.start {day['start'].strftime('%s')} <= start_time {start_time.strftime('%s')} < day.end {day['end'].strftime('%s')}") + return day["index"] + + raise Warning(" illegal start time: " + start_time.isoformat()) + + def add_events_from(self, other_schedule, id_offset=None, options={}, context: EventSourceInterface = {}): + offset = ( + other_schedule.conference_start() - self.conference_start() + ).days + days = other_schedule.days() + # worksround if other schedule starts with index 0 instead of 1 + if days[0]["index"] == 0: + offset += 1 + + self["version"] += " " + other_schedule.version() + + if offset: + log.warning(" calculated conference start day index offset: {}".format(offset)) + + for day in days: + target_day = day["index"] + offset + + if target_day < 1: + log.warning(f" ignoring day {day['date']} from {other_schedule.conference('acronym')}, as primary schedule starts at {self.conference('start')}") + continue + + if day["date"] != self.day(target_day)["date"]: + log.error(f" ERROR: the other schedule's days have to match primary schedule, in some extend {day['date']} != {self.day(target_day)['date']}!") + return False + + self.add_rooms(other_schedule.conference("rooms"), context) + + for room in day["rooms"]: + if options and "room-map" in options and room in options["room-map"]: + target_room = options["room-map"][room] + + for event in day["rooms"][room]: + event["room"] = target_room + elif options and "room-prefix" in options: + target_room = options["room-prefix"] + room + else: + target_room = room + + events = [] + for event in day["rooms"][room]: + if options.get("track"): + event["track"] = options['track'](event) if callable(options["track"]) else options["track"] + + if options.get("do_not_record"): + event["do_not_record"] = options['do_not_record'](event) if callable(options["do_not_record"]) else options["do_not_record"] + + if options.get("remove_title_additions"): + # event["title"], subtitle, event_type = re.match(r"^(.{15,}?)(?:(?::| [–-]+) (.+?))?(?: \((.+?)\))?$", event["title"]).groups() + + match = re.match(r"^(.{5,}?)(?:(?::| [–-]+) (.+?))?(?: \((.+?)\))?$", event["title"]) + if match: + event["title"], subtitle, event_type = match.groups() + + if not event.get("subtitle") and subtitle: + event["subtitle"] = subtitle + + if options.get("rewrite_id_from_question"): + q = next( + ( + x + for x in event["answers"] + if x.question == options["rewrite_id_from_question"] + ), + None, + ) + if q is not None: + event["id"] = q["answer"] + elif id_offset: + event["id"] = int(event["id"]) + id_offset + # TODO? offset for person IDs? + + # workaround for fresh pretalx instances + elif options.get("randomize_small_ids") and int(event["id"]) < 1500: + event["id"] = int(re.sub("[^0-9]+", "", event["guid"])[0:4]) + + # overwrite slug for pretalx schedule.json input + if options.get("overwrite_slug", False): + event["slug"] = "{slug}-{id}-{name}".format( + slug=self.conference("acronym").lower(), + id=event["id"], + name=tools.normalise_string(event["title"].split(":")[0]), + ) + + if options.get("prefix_person_ids"): + prefix = options.get("prefix_person_ids") + for person in event["persons"]: + person["id"] = f"{prefix}-{person['id']}" + + events.append(event if isinstance(event, Event) else Event(event, origin=other_schedule)) + + # copy whole day_room to target schedule + self.add_room_with_events(target_day, target_room, events) + return True + + def find_event(self, id=None, guid=None): + if not id and not guid: + raise RuntimeError("Please provide either id or guid") + + if id: + result = self.foreach_event( + lambda event: event if event["id"] == id else None + ) + else: + result = self.foreach_event( + lambda event: event if event["guid"] == guid else None + ) + + if len(result) > 1: + log.warning("Warning: Found multiple events with id " + id) + return result + + if len(result) == 0: + raise Warning("could not find event with id " + id) + # return None + + return result[0] + + def remove_event(self, id=None, guid=None): + if not id and not guid: + raise RuntimeError("Please provide either id or guid") + + for day in self["conference"]["days"]: + for room in day["rooms"]: + for event in day["rooms"][room]: + if ( + event["id"] == id + or event["id"] == str(id) + or event["guid"] == guid + ): + log.info("removing", event["title"]) + day["rooms"][room].remove(event) + + # dict_to_etree from http://stackoverflow.com/a/10076823 + + # TODO: + # * check links conversion + # * ' vs " in xml + # * logo is in json but not in xml + # formerly named dict_to_schedule_xml() + def xml(self, method="string"): + root_node = None + + def dict_to_attrib(d, root): + assert isinstance(d, dict) + for k, v in d.items(): + assert _set_attrib(root, k, v) + + def _set_attrib(tag, k, v): + if isinstance(v, str): + tag.set(k, v) + elif isinstance(v, int): + tag.set(k, str(v)) + else: + log.error(" error: unknown attribute type %s=%s" % (k, v)) + + def _to_etree(d, node, parent=""): + if not d: + pass + elif isinstance(d, str): + node.text = d + elif isinstance(d, int): + node.text = str(d) + elif parent == "person": + node.text = d.get("public_name") or d.get('full_public_name') or d.get('full_name') or d.get('name') + if "id" in d: + _set_attrib(node, "id", d["id"]) + if "guid" in d: + _set_attrib(node, "guid", d["guid"]) + + elif ( + isinstance(d, dict) + or isinstance(d, OrderedDict) + or isinstance(d, Event) + or isinstance(d, ScheduleDay) + ): + # location of base_url sadly differs in frab's json and xml serialization :-( + if parent == "schedule" and "base_url" in d: + d["conference"]["base_url"] = d["base_url"] + del d["base_url"] + + # count variable is used to check how many items actually end as elements + # (as they are mapped to an attribute) + count = len(d) + recording_license = "" + for k, v in d.items(): + if parent == "day": + if k[:4] == "day_": + # remove day_ prefix from items + k = k[4:] + + if ( + k == "id" + or k == "guid" + or (parent == "day" and isinstance(v, (str, int))) + or parent == "generator" + or parent == "track" + or parent == "color" + ): + _set_attrib(node, k, v) + count -= 1 + elif k == "url" and parent in ["link", "attachment"]: + _set_attrib(node, "href", v) + count -= 1 + elif k == "title" and parent in ["link", "attachment"]: + node.text = v + elif count == 1 and isinstance(v, str): + node.text = v + else: + node_ = node + + if parent == "room": + # create room tag for each instance of a room name + node_ = ET.SubElement(node, "room") + node_.set("name", k or '') + if k in self._room_ids and self._room_ids[k]: + node_.set("guid", self._room_ids[k]) + + k = "event" + + if k == "days": + # in the xml schedule days are not a child of a conference, + # but directly in the document node + node_ = root_node + + # ignore room list on confernce + if k == 'rooms' and parent == 'conference': + continue + # special handing for collections: days, rooms etc. + elif k[-1:] == "s": + # don't ask me why the pentabarf schedule xml schema is so inconsistent --Andi + # create collection tag for specific tags, e.g. persons, links etc. + if parent == "event": + node_ = ET.SubElement(node, k) + + # remove last char (which is an s) + k = k[:-1] + # different notation for conference length in days + elif parent == "conference" and k == "daysCount": + k = "days" + # special handling for recoding_licence and do_not_record flag + elif k == "recording_license": + # store value for next loop iteration + recording_license = v + # skip forward to next loop iteration + continue + elif k == "do_not_stream": + # we dont expose this flag to the schedule.xml, only in schedule.json + continue + elif k == "do_not_record" or k == "recording": + k = "recording" + # not in schedule.json: license information for an event + v = { + "license": recording_license, + "optout": v, + } + # new style schedule.json (version 2022-12) + elif k == "optout": + v = "true" if v is True else "false" + + # iterate over lists + if isinstance(v, list): + for element in v: + _to_etree(element, ET.SubElement(node_, k), k) + # don't single empty room tag, as we have to create one for each room, see above + elif parent == "day" and k == "room": + _to_etree(v, node_, k) + else: + _to_etree(v, ET.SubElement(node_, k), k) + else: + assert d == "invalid type" + + assert isinstance(self, dict) + + root_node = ET.Element("schedule") + root_node.set("{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation", "https://c3voc.de/schedule/schema.xsd") + _to_etree(self, root_node, "schedule") + + if method == 'xml': + return root_node + elif method == 'bytes': + return ET.tostring(root_node, pretty_print=True, xml_declaration=True) + + return ET.tostring(root_node, pretty_print=True, encoding="unicode", doctype='') + + def json(self, method="json", **args): + json = { + "$schema": "https://c3voc.de/schedule/schema.json", + "schedule": { + "generator": self.generator or tools.generator_info(), + **self + } + } + if method == 'string': + return json.dumps(self, indent=2, cls=ScheduleEncoder, **args) + + return json + + def filter(self, name: str, rooms: Union[List[Union[str, Room]], Callable]): + log.info(f'\nExporting {name}... ') + schedule = self.copy(name) + + if callable(rooms): + def filterRoom(room): + return rooms(room) + else: + room_names = set() + room_guids = set() + for room in rooms: + if isinstance(room, Room): + if room.guid: + room_guids.add(room.guid) + if room.name: + room_names.add(room.name) + + def filterRoom(room: Room): + if isinstance(room, Room): + return room.name in room_names or \ + room.guid in room_guids + else: + return room['name'] in room_names or \ + room.get('guid', '') in room_guids + + for room in schedule.rooms(mode='obj'): + if not filterRoom(room): + log.info(f"deleting room {room.name} on conference") + schedule.remove_room(room.name) + + schedule['version'] = self.version().split(';')[0] + return schedule + + def export(self, prefix_or_target): + """Export schedule to json and xml files, validate xml""" + + target_json = None + target_xml = None + + if prefix_or_target.endswith(".json"): + target_json = prefix_or_target + elif prefix_or_target.endswith(".xml"): + target_xml = prefix_or_target + else: + target_json = f"{prefix_or_target}.schedule.json" + target_xml = f"{prefix_or_target}.schedule.xml" + + if target_json: + with open(target_json, "w") as fp: + json.dump(self.json(), fp, indent=2, cls=ScheduleEncoder) + + # TODO we should also validate the json file here + + if target_xml: + with open(target_xml, "w") as fp: + fp.write(self.xml()) + + # TODO use python XML validator instead of shell call + # validate xml + result = os.system( + f'/bin/bash -c "{validator} {target_xml} 2>&1 {validator_filter}; exit \\${{PIPESTATUS[0]}}"' + ) + if result != 0 and validator_filter: + log.warning(" (validation errors might be hidden by validator_filter)") + + def __str__(self): + return json.dumps(self, indent=2, cls=ScheduleEncoder) + + +class ScheduleEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Schedule): + return obj.json() + if isinstance(obj, ScheduleDay): + return obj + if isinstance(obj, Event): + return obj.json() + return json.JSONEncoder.default(self, obj) diff --git a/voc/tools.py b/voc/tools.py new file mode 100644 index 0000000..149638b --- /dev/null +++ b/voc/tools.py @@ -0,0 +1,383 @@ +# -*- coding: UTF-8 -*- +from datetime import timedelta +from logging import Logger +import argparse +from os import path +import os +import uuid +import json +import re +import sys + +from typing import Dict, Union +from collections import OrderedDict +from bs4 import Tag +from git import Repo + +import __main__ + +sos_ids = {} +last_edited = {} +next_id = 1000 +generated_ids = 0 +NAMESPACE_VOC = uuid.UUID('0C9A24B4-72AA-4202-9F91-5A2B6BFF2E6F') +VERSION = None + +log = Logger(__name__) + +def DefaultOptionParser(local=False): + parser = argparse.ArgumentParser() + parser.add_argument("--online", action="store_true", dest="online", default=False) + parser.add_argument( + "--fail", action="store_true", dest="exit_when_exception_occours", default=local + ) + parser.add_argument("--stats", action="store_true", dest="only_stats", default=False) + parser.add_argument("--git", action="store_true", dest="git", default=False) + parser.add_argument("--debug", action="store_true", dest="debug", default=local) + return parser + +def write(x): + sys.stdout.write(x) + sys.stdout.flush() + + +def set_base_id(value): + global next_id + next_id = value + + +def get_id(guid, length=None): + # use newer mode without external state if length is set + if length: + # generate numeric id from first x numbers from guid, stipping leading zeros + return int(re.sub("^0+", "", re.sub("[^0-9]+", "", guid))[0:length]) + + global sos_ids, next_id, generated_ids + if guid not in sos_ids: + # generate new id + sos_ids[guid] = next_id + next_id += 1 + generated_ids += 1 + + return sos_ids[guid] + + +def load_sos_ids(): + global sos_ids, next_id, generated_ids + if path.isfile("_sos_ids.json"): + with open("_sos_ids.json", "r") as fp: + # maintain order from file + temp = fp.read() + sos_ids = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(temp) + + next_id = max(sos_ids.values()) + 1 + + +# write sos_ids to disk +def store_sos_ids(): + global sos_ids + with open("_sos_ids.json", "w") as fp: + json.dump(sos_ids, fp, indent=4) + + +def gen_random_uuid(): + return uuid.uuid4() + + +def gen_person_uuid(email): + return str(uuid.uuid5(uuid.NAMESPACE_URL, 'acct:' + email)) + + +def gen_uuid(value): + return str(uuid.uuid5(NAMESPACE_VOC, str(value))) + + +# deprecated, use Schedule.foreach_event() instead +# TODO remove +def foreach_event(schedule, func): + out = [] + for day in schedule["schedule"]["conference"]["days"]: + for room in day['rooms']: + for event in day['rooms'][room]: + out.append(func(event)) + + return out + + +def copy_base_structure(subtree, level): + ret = OrderedDict() + if level > 0: + for key, value in subtree.iteritems(): + if isinstance(value, (str, int)): + ret[key] = value + elif isinstance(value, list): + ret[key] = copy_base_structure_list(value, level - 1) + else: + ret[key] = copy_base_structure(value, level - 1) + return ret + + +def copy_base_structure_list(subtree, level): + ret = [] + if level > 0: + for value in subtree: + if isinstance(value, (str, int)): + ret.append(value) + elif isinstance(value, list): + ret.append(copy_base_structure_list(value, level - 1)) + else: + ret.append(copy_base_structure(value, level - 1)) + return ret + + +def normalise_string(string): + string = string.lower() + string = string.replace(u"ä", 'ae') + string = string.replace(u'ö', 'oe') + string = string.replace(u'ü', 'ue') + string = string.replace(u'ß', 'ss') + string = re.sub('\W+', '\_', string.strip()) # replace whitespace with _ + # string = filter(unicode.isalnum, string) + string = re.sub('[^a-z0-9_]+', '', string) # TODO: is this not already done with \W+ line above? + string = string.strip('_') # remove trailing _ + + return string + + +def normalise_time(timestr): + timestr = timestr.replace('p.m.', 'pm') + timestr = timestr.replace('a.m.', 'am') + # workaround for failure in input file format + if timestr.startswith('0:00'): + timestr = timestr.replace('0:00', '12:00') + + return timestr + + +def format_duration(value: Union[int, timedelta]) -> str: + if type(value) == timedelta: + minutes = round(value.total_seconds() / 60) + else: + minutes = value + + return '%d:%02d' % divmod(minutes, 60) + + +# from https://git.cccv.de/hub/hub/-/blob/develop/src/core/utils.py +_RE_STR2TIMEDELTA = re.compile(r'((?P\d+?)hr?\s*)?((?P\d+?)m(ins?)?\s*)?((?P\d+?)s)?') + +def str2timedelta(s): + if ':' in s: + parts = s.split(':') + kwargs = {'seconds': int(parts.pop())} + if parts: + kwargs['minutes'] = int(parts.pop()) + if parts: + kwargs['hours'] = int(parts.pop()) + if parts: + kwargs['days'] = int(parts.pop()) + return timedelta(**kwargs) + + parts = _RE_STR2TIMEDELTA.match(s) + if not parts: + return + parts = parts.groupdict() + time_params = {} + for name, param in parts.items(): + if param: + time_params[name] = int(param) + return timedelta(**time_params) + +def parse_json(text): + # this more complex way is necessary + # to maintain the same order as in the input file in python2 + return json.JSONDecoder(object_pairs_hook=OrderedDict).decode(text) + + +def load_json(filename): + with open(filename, "r") as fp: + # data = json.load(fp) + # maintain order from file + data = parse_json(fp.read()) + return data + + +def get_version(): + global VERSION + try: + if VERSION is None: + repo = Repo(path=__file__, search_parent_directories=True) + sha = repo.head.object.hexsha + VERSION = repo.git.rev_parse(sha, short=5) + except ValueError: + pass + return VERSION + + +def generator_info(): + module = path.splitext(path.basename(__main__.__file__))[0] \ + .replace('schedule_', '') + return ({ + "name": "voc/schedule/" + module, + "version": get_version() + }) + + +def parse_html_formatted_links(td: Tag) -> Dict[str, str]: + """ + Returns a dictionary containing all HTML formatted links found + in the given table row. + + - Key: The URL of the link. + - Value: The title of the link. Might be the same as the URL. + + :param td: A table row HTML tag. + """ + links = {} + for link in td.find_all("a"): + href = link.attrs["href"] + title = link.attrs["title"].strip() + text = link.get_text().strip() + links[href] = title if text is None else text + + return links + + +def ensure_folders_exist(output_dir, secondary_output_dir): + global local + local = False + if not os.path.exists(output_dir): + try: + if not os.path.exists(secondary_output_dir): + os.mkdir(output_dir) + else: + output_dir = secondary_output_dir + local = True + except Exception: + print('Please create directory named {} if you want to run in local mode'.format(secondary_output_dir)) + exit(-1) + os.chdir(output_dir) + + if not os.path.exists('events'): + os.mkdir('events') + + return local + + +def export_filtered_schedule(output_name, parent_schedule, filter): + write('\nExporting {} schedule... '.format(output_name)) + schedule = parent_schedule.copy(output_name) + for day in schedule.days(): + room_keys = list(day['rooms'].keys()) + for room_key in room_keys: + if not filter(room_key): + del day['rooms'][room_key] + + print('\n {}: '.format(output_name)) + for room in schedule.rooms(): + print(' - {}'.format(room)) + + schedule.export(output_name) + return schedule + + +def git(args): + os.system(f'/usr/bin/env git {args}') + + +def commit_changes_if_something_relevant_changed(schedule): + content_did_not_change = os.system("/usr/bin/env git diff -U0 --no-prefix | grep -e '^[+-] ' | grep -v version > /dev/null") + + if content_did_not_change: + print('nothing relevant changed, reverting to previous state') + git('reset --hard') + exit(0) + + git('add *.json *.xml events/*.json') + git('commit -m "version {}"'.format(schedule.version())) + git('push') + + +# remove talks starting before 9 am +def remove_too_early_events(room): + from .schedule import Event + + for e in room: + event = e if isinstance(e, Event) else Event(e) + start_time = event.start + if start_time.hour > 4 and start_time.hour < 9: + print('removing {} from full schedule, as it takes place at {} which is too early in the morning'.format(event['title'], start_time.strftime('%H:%M'))) + room.remove(event) + else: + break + + +# harmonize event types +def harmonize_event_type(event, options): + type_mapping = { + + # TALKS + 'talk': 'Talk', + 'talk/panel': 'Talk', + 'vortrag': 'Talk', + 'lecture': 'Talk', + 'beitrag': 'Talk', + 'track': 'Talk', + 'live on stage': 'Talk', + 'recorded': 'Talk', + '60 min Talk + 15 min Q&A': 'Talk', + '30 min Short Talk + 10 min Q&A': 'Talk', + + # LIGHTNING TALK + 'lightningtalk': 'Lightning Talk', + 'lightning_talk': 'Lightning Talk', + 'lightning-talk': 'Lightning Talk', + 'Lightning': 'Lightning Talk', + + # MEETUP + 'meetup': 'Meetup', + + # OTHER + 'other': 'Other', + '': 'Other', + 'Pausenfüllmaterial': 'Other', + + # PODIUM + 'podium': 'Podium', + + # PERFORMANCE + 'theater': 'Performance', + 'performance': 'Performance', + + # CONCERT + 'konzert': 'Concert', + 'concert': 'Concert', + + # DJ Set + 'dj set': 'DJ Set', + 'DJ Set': 'DJ Set', + + # WORKSHOP + 'workshop': 'Workshop', + + # LIVE-PODCAST + 'Live-Podcast': 'Live-Podcast', + } + + type = event.get('type', '').split(' ') + if not type or not type[0]: + event['type'] = 'Other' + elif event.get('type') in type_mapping: + event['type'] = type_mapping[event['type']] + elif event.get('type').lower() in type_mapping: + event['type'] = type_mapping[event['type'].lower()] + elif type[0] in type_mapping: + event['type'] = type_mapping[type[0]] + elif type[0].lower() in type_mapping: + event['type'] = type_mapping[type[0].lower()] + elif options.debug: + log.debug(f"Unknown event type: {event['type']}") + + if event.get('language') is not None: + event['language'] = event['language'].lower() + diff --git a/voc/voctoimport.py b/voc/voctoimport.py new file mode 100644 index 0000000..05d8a83 --- /dev/null +++ b/voc/voctoimport.py @@ -0,0 +1,136 @@ +from os import getenv +from sys import stdout +import json +import time +import argparse + +from gql import Client, gql +from gql.transport.aiohttp import AIOHTTPTransport +# from gql.transport.exceptions import TransportQueryError + +try: + from .schedule import Schedule, Event +except ImportError: + from schedule import Schedule, Event + +transport = AIOHTTPTransport( + url=getenv('IMPORT_URL', 'https://import.c3voc.de/graphql'), + headers={'Authorization': getenv('IMPORT_TOKEN', 'Basic|Bearer|Token XXXX')} +) +client = Client(transport=transport, fetch_schema_from_transport=True) +args = None + + +def get_conference(acronym): + return client.execute(gql(''' + query getConference($acronym: String!) { + conference: conferenceBySlug(slug: $acronym) { + id + title + } + }'''), variable_values={'acronym': acronym})['conference'] + + +def add_event(conference_id, event): + data = { + "event": { + 'talkid': event['id'], + 'persons': ', '.join([p for p in event.persons()]), + **(event.voctoimport()), + 'abstract': event.get('abstract') or '', + 'published': False, + 'conferenceId': conference_id + } + } + + query = gql(''' + mutation upsertEvent($input: UpsertEventInput!) { + upsertEvent(input: $input) { + clientMutationId + } + } + ''') + + try: + client.execute(query, {'input': data}) + stdout.write('.') + stdout.flush() + except Exception as e: + print(json.dumps(data, indent=2)) + print() + print(e) + print() + time.sleep(10) + + +def remove_event(event_guid): + try: + client.execute(gql(''' + mutation deleteEvent($guid: UUID!) { + deleteEvent(input: {guid: $guid}) { deletedEventNodeId } + } + '''), {'input': {'guid': event_guid}}) + except Exception as e: + print(e) + print() + + +class VoctoImport: + schedule = None + conference = None + + def __init__(self, schedule: Schedule, create=False): + global args + + self.schedule = schedule + acronym = args.conference or args.acronym or schedule.conference('acronym') + self.conference = get_conference(acronym) + if not self.conference: + raise Exception(f'Unknown conference {acronym}') + pass + + def upsert_event(self, event): + add_event(self.conference['id'], Event(event)) + + def depublish_event(self, event_guid): + remove_event(event_guid) + + +def push_schedule(schedule: Schedule, create=False): + instace = VoctoImport(schedule, create) + schedule.foreach_event(instace.upsert_event) + + +def run(args): + if args.url or args.acronym: + schedule = Schedule.from_url( + args.url or f'https://pretalx.c3voc.de/{args.acronym}/schedule/export/schedule.json' + ) + else: + schedule = Schedule.from_file('jev22/channels.schedule.json') + + instace = VoctoImport(schedule) + + def upsert_event(event): + if (len(args.room) == 0 or event['room'] in args.room) and event['do_not_record'] is not True: + instace.upsert_event(event) + + try: + schedule.foreach_event(upsert_event) + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--url', action='store', help='url to schedule.json') + parser.add_argument('--acronym', '-a', help='the conference acronym in pretalx.c3voc.de') + parser.add_argument('--conference', '-c', help='the confence slug in import.c3voc.de') + parser.add_argument('--room', '-r', action='append', help='filter rooms (multiple possible)') + + args = parser.parse_args() + + print(args) + + run(args) + print('\nimport done') diff --git a/voc/webcal.py b/voc/webcal.py new file mode 100644 index 0000000..4cf100f --- /dev/null +++ b/voc/webcal.py @@ -0,0 +1,57 @@ +import re +import ics +import requests + +from voc import GenericConference +from voc.event import Event, EventSourceInterface +from voc.schedule import Schedule, ScheduleException +from voc.tools import format_duration, gen_person_uuid + + +class WebcalConference(GenericConference, EventSourceInterface): + def __init__(self, **args): + GenericConference.__init__(self, **args) + + def schedule(self, template: Schedule): + if not self.schedule_url or self.schedule_url == 'TBD': + raise ScheduleException(' has no schedule url yet – ignoring') + + url = re.sub(r'^webcal', 'http', self.schedule_url) + data = requests.get(url, timeout=10).text + cal = ics.Calendar(data) + + schedule = template.copy(self['name']) or Schedule(conference=self) + + for e in cal.events: + event = Event(convert_to_dict(e, self), origin=self) + schedule.add_event(event) + + return schedule + + +def convert_to_dict(e: ics.Event, context: WebcalConference) -> dict: + title, subtitle, event_type = re.match(r"^(.+?)(?:( ?[:–] .+?))?(?: \((.+?)\))?$", e.name).groups() + track, = list(e.categories) or [None] + return { + "guid": e.uid, + "title": title, + "subtitle": subtitle, + "abstract": e.description, + "description": '', # empty description for pretalx importer (temporary workaround) + "date": e.begin.isoformat(), + "start": e.begin.format("HH:mm"), + "duration": format_duration(e.duration), + "room": e.location or context['name'], + "persons": [{ + "name": p.common_name, + "guid": gen_person_uuid(p.email.replace('mailto:', '')), + # TODO: add p.role? + } for p in e.attendees], + "track": track, + "type": event_type or 'Other', + "url": e.url or None, + } + + +if __name__ == '__main__': + WebcalConference() \ No newline at end of file diff --git a/voc/webcal2.py b/voc/webcal2.py new file mode 100644 index 0000000..49629d9 --- /dev/null +++ b/voc/webcal2.py @@ -0,0 +1,119 @@ +import re +import icalendar +import requests + +from voc import GenericConference +from voc.event import Event, EventSourceInterface +from voc.schedule import Schedule, ScheduleException +from voc.tools import format_duration, gen_person_uuid, gen_uuid + + +class WebcalConference2(GenericConference, EventSourceInterface): + def __init__(self, **args): + GenericConference.__init__(self, **args) + + def schedule(self, template: Schedule): + if not self.schedule_url or self.schedule_url == 'TBD': + raise ScheduleException(' has no schedule url yet – ignoring') + + url = re.sub(r'^webcal', 'http', self.schedule_url) + r = requests.get(url, timeout=10) + if r.status_code != 200: + raise ScheduleException(f' Failed to retrieve iCal feed: Error ({r.status_code})') + cal = icalendar.Calendar.from_ical(r.text) + + schedule = template.copy(self['name']) or Schedule(conference=self) + + for e in cal.walk('vevent'): + try: + event = Event(convert_to_dict(e, self), origin=self) + schedule.add_event(event) + except Exception as e: + print(e) + + return schedule + + +def convert_to_dict(e: icalendar.Event, context: WebcalConference2) -> dict: + # title, subtitle, event_type = re.match(r"^(.+?)(?:( ?[:–] .+?))?(?: \((.+?)\))?$", e.name).groups() + track, = [ str(c) for c in e.get('categories').cats] or [None] + begin = e['dtstart'].dt + end = e['dtend'].dt + duration = end - begin + + return { k: (v if isinstance(v, list) or v is None else str(v)) for k, v in { + "guid": gen_uuid(e['uid']), + "id": e['event-id'], + "title": e.get('summary'), + "subtitle": '', + "abstract": e['description'], + "description": '', # empty description for pretalx importer (temporary workaround) + "date": begin.isoformat(), + "start": begin.strftime("%H:%M"), + "duration": format_duration(duration), + "room": track, #context['name'], + "persons": [{ + **p, + "id": 0 + } for p in extract_persons(e)], + "track": track, + "language": 'de', + "type": 'Session' or 'Other', + "url": e.get('url', None), + }.items() } + +def extract_persons(e: icalendar.Event) -> list: + person_str = str(e.get('location', '')).replace(' und ', '; ').strip() + print(person_str) + # persons = re.split(r'\s*[,;/]\s*', person_str) + persons = re.split(r'[,;/](?![^()]*\))', person_str) + + if len(persons) == 0: + return [] + pattern = r'([^()]+)(?:\((\w{2,3}\s+)?([^)]*)\))' + + result = [] + for p in persons: + # p is either "name (org)" or or "name (org role)" or "name (name@org.tld)" + match = re.match(pattern, p) + if match: + name, org, role = match.groups() + if role and '@' in role: + match = re.search(r'@(.+)(\.de)?$', role) + org = match.group(1) + result.append({ + "name": name.strip(), + "org": org.strip(), + "email": role.strip(), + "guid": gen_person_uuid(role) + }) + else: + if not org: + if len(role) <= 3: + org = role + role = None + else: + # try catch `Distribution Cordinator, ZER` and split org + m = re.match(r'^(.+?), (\w{2,3})$', role) + if m: + org = m.group(2) + role = m.group(1) + + if name: + result.append({ + "name": name.strip(), + "org": org.strip() if org else None, + "role": role.strip() if role else None, + }) + elif p: + result.append({ + "name": p.strip(), + }) + + print(result) + print() + return result + + +if __name__ == '__main__': + WebcalConference() \ No newline at end of file