Compare commits

..

19 Commits

Author SHA1 Message Date
f84f357056 sqlExport: Use FROM DUAL in insertDistinct to fix older mariadb 2020-04-01 19:21:41 -04:00
bdf3b84c74 sqlExport: Simplify insert functions with f strings 2020-04-01 17:46:41 -04:00
d5ecf50943 sqlExport: WIP: Even worse fix for missing transactions 2020-04-01 17:38:49 -04:00
925e1abe80 Revert "sqlExport: Fix missing transactions by making whole row unique"
This reverts commit 6c862b9fb41f863cfc5d0ef4ffc863c4fe43d9fd.

Turns out that doesn't work, and mariadb was just silently failing.
Reopens #2.
2020-03-29 00:32:32 -04:00
74827ac9ad sqlExport: Fix missing transactions by making whole row unique
This is probably not a good way to fix this, but it does work.
Fixes #2
2020-03-29 00:32:32 -04:00
ae0cf430b5 sqlExport: Add some more fields to members table 2020-03-29 00:32:32 -04:00
17c9da6f9b sqlExport: Add 'Audit Date' to members table 2020-03-29 00:32:32 -04:00
44e560cd34 sqlExport: Remove name from transactions assertion
because apparently basic data consistency is too much to ask for
2020-03-29 00:32:32 -04:00
721000ce43 sqlExport: Change transaction start date to 2010 2020-03-29 00:32:32 -04:00
b417821703 sqlExport: Use both CSV and json transaction sources to get more data 2020-03-29 00:32:32 -04:00
65b79cad99 sqlExport: Set db character set to utf8 2020-03-29 00:32:32 -04:00
6ab2b7bd9c sqlExport: Add some informational prints 2020-03-29 00:32:32 -04:00
a6e596cfd4 sqlExport: Read membershipworks database info from passwords.py file 2020-03-29 00:32:32 -04:00
516813b895 sqlExport: Switch from REPLACE to INSERT...ON DUPLICATE KEY UPDATE
REPLACE DELETES the row even when there was no change, creating a
history entry even when none was needed or useful
2020-03-29 00:32:32 -04:00
01e08e1007 sqlExport: Delete labels that are no longer valid 2020-03-29 00:32:32 -04:00
b82da10073 Add sqlExport.py to README 2020-03-29 00:32:32 -04:00
8edaaf0df9 sqlExport: Migrate to mariadb, improve types 2020-03-29 00:32:32 -04:00
8b54dc7fe3 sqlExport: Merge the logic of members and transactions tables, w/ mappings defined in yaml 2020-03-29 00:32:32 -04:00
38ba551f1e sqlExport: WIP: sort of working export to SQL 2020-03-29 00:31:10 -04:00
35 changed files with 1318 additions and 2673 deletions

3
.gitignore vendored
View File

@ -1,3 +1,2 @@
__pycache__/
/venv/
/config.yaml
/passwords.py

View File

@ -1,39 +1,33 @@
# Claremont MakerSpace Member Plumbing
# Claremont Makerspace Member Plumbing
This repo contains a set of scripts to sync data around for the Claremont MakerSpace. They primarily revolve around pulling member data from [MembershipWorks](https://membershipworks.com/) and pushing it out to various systems at the Space.
This repo contains a set of scripts to sync data around for the Claremont MakerSpace. They primarilly revolve around pulling member data from [MembershipWorks](https://membershipworks.com/) and pushing it out to various systems at the Space.
## Setup
## `passwords.py`
This project uses [Poetry](https://python-poetry.org/) for dependency management. Typical usage is first running `poetry install` to create a virtualenv and install dependencies, then running `poetry run <script>` to start a specific script.
Most of the scripts require a `passwords.py` file, defining the usernames in passwords for various services. This file is not distributed in this repository, for obvious reasons, but here is an example:
## Config
```python
DOOR_USERNAME = "user1"
DOOR_PASSWORD = "password1"
Many of the scripts use data from a `config.yaml` in the current working directory when they are run. There is an example config in [`config.example.yaml`](./config.example.yaml) which has been stripped of authentication information.
MEMBERSHIPWORKS_USERNAME = "user2"
MEMBERSHIPWORKS_PASSWORD = "password2"
## Scripts
MEMBERSHIPWORKS_DB = {
"database": "db_name_here",
"user": "user3",
"password": "password3"
}
```
The primary entry points have scripts entries (`tool.poetry.scripts`) in [`pyproject.toml`](./pyproject.toml). They assume that they are being run from a module, so must be run with `poetry run <script>` or `python -m memberPlumbing.<script>`.
## `doorUpdater.py`
### `doorUpdater`
Retrieves member information from MembershipWorks and pushes it out to the HID Edge Evo SOLO controllers that do access controll at the Space. Configuration lives in `config.yaml`.
Retrieves member information from MembershipWorks and pushes it out to the HID Edge Evo SOLO controllers that do access control at the Space. Configuration lives in `config.yaml`.
### `ucsAccounts`
## `ucsAccounts.py`
Retrieves member information from MembershipWorks and pushes it out to [UCS](https://www.univention.com/products/ucs/), which we use as a domain controller for the Windows computers at the Space.
### `sqlExport`
## `sqlExport.py`
Retrieves account and transaction information from MembershipWorks, and pushes it to a MariaDB database for use in other projects. Schemas are defined with [peewee](peewee-orm.com) in [`memberPlumbing/mw_models.py`](./memberPlumbing/mw_models.py).
### `upcomingEvents`
Retrieves upcoming events from MembershipWorks and formats them for a WordPress post.
### `hidEvents`
Retrieves events from the HID Evo Solo door controllers, and pushes them to a SQL database.
## Systemd
There are systemd units in the [`systemd`](./systemd/) folder, which can be used to run the various scripts regularly.
Retrieves account and transaction information from MembershipWorks, and pushes it to a MariaDB database for use in other projects. Schemas are defined (somewhat oddly, to be fair) in `tableMapping.yaml`.

26
common.py Normal file
View File

@ -0,0 +1,26 @@
from ruamel.yaml import YAML
import os
from lib.hid.DoorController import DoorController
from lib.MembershipWorks import MembershipWorks
from passwords import DOOR_USERNAME, DOOR_PASSWORD
from passwords import MEMBERSHIPWORKS_USERNAME, MEMBERSHIPWORKS_PASSWORD
try:
with open(os.path.dirname(os.path.abspath(__file__)) + "/config.yaml") as f:
config = YAML().load(f)
except NameError:
with open("config.yaml") as f:
config = YAML().load(f)
doors = {doorName: DoorController(doorData['ip'],
DOOR_USERNAME, DOOR_PASSWORD,
name=doorName, access=doorData['access'])
for doorName, doorData in config["doors"].items()}
memberLevels = config['memberLevels']
doorSpecificSchedules = config['doorSpecificSchedules']
membershipworks = MembershipWorks()
membershipworks.login(MEMBERSHIPWORKS_USERNAME, MEMBERSHIPWORKS_PASSWORD)

View File

@ -1,4 +1,4 @@
doorControllers:
doors:
Studio Space: {ip: 172.18.51.11, access: Studio Space}
Front Door: {ip: 172.18.51.12, access: Front Door}
Metal Shop: {ip: 172.18.51.13, access: Metal Shop}
@ -26,26 +26,3 @@ doorSpecificSchedules:
- Metal Shop
- Wood Shop
- Wood Shop Rear
DOOR_USERNAME: ""
DOOR_PASSWORD: ""
MEMBERSHIPWORKS_USERNAME: ""
MEMBERSHIPWORKS_PASSWORD: ""
# arguments for https://udm-rest-client.readthedocs.io/en/latest/udm_rest_client.html#udm_rest_client.udm.UDM
UCS:
url: ""
username: ""
password: ""
MEMBERSHIPWORKS_DB:
database: ""
user: ""
password: ""
HID_DB:
database: ""
user: ""
password: ""

285
doorUpdater.py Executable file
View File

@ -0,0 +1,285 @@
#!/usr/bin/env python3
import copy
from common import doors, membershipworks, memberLevels, doorSpecificSchedules
from lib.hid.Credential import Credential
from lib.hid.DoorController import ROOT, E
class Member():
def __init__(self, forename="", surname="", membershipWorksID="",
middleName="", email="", phone="",
cardholderID=None, doorAccess=[],
credentials=set(), levels=[], extraLevels=[], schedules=[]):
self.forename = forename
self.surname = surname
self.membershipWorksID = membershipWorksID
self.middleName = middleName
self.email = email
self.phone = phone
self.cardholderID = cardholderID
self.doorAccess = doorAccess
self.credentials = credentials
self.levels = levels
self.schedules = schedules
def __str__(self):
return f"""Name: {self.forename} | {self.middleName} | {self.surname}
MembershipWorks ID: {self.membershipWorksID}
Email: {self.email}
Phone: {self.phone}
Cardholder ID: {self.cardholderID}
doorAccess: {self.doorAccess}
Credentials: {self.credentials}
Levels: {self.levels}
Schedules: {self.schedules}
"""
class MembershipworksMember(Member):
def __init__(self, data, formerMember=False):
super().__init__(data["First Name"],
data["Last Name"],
membershipWorksID=data["Account ID"],
email=data["Email"],
phone=data["Phone"])
if data["Access Card Number"] != "":
self.credentials = set([Credential(
code=(data["Access Card Facility Code"],
data["Access Card Number"]))])
else:
self.credentials = set()
self.onHold=data["Account on Hold"] != ""
self.limitedOperations=data['Access Permitted During Limited Operations'] == "Y"
self.formerMember=formerMember
levels = {k: v for k, v in memberLevels.items() if data[k] == k}
self.levels = list(levels.keys())
self.schedules = list(levels.values())
self.extraLevels = {
schedule: sum((doors for prop, doors in props.items()
if data[prop] == "Y"),
[])
for schedule, props in doorSpecificSchedules.items()
}
self.doorAccess = [
door for door, doorData in doors.items()
if data["Access " + doorData.access + "?"] == "Y"
]
def to_DoorMember(self, door):
doorLevels = [k for k, v in self.extraLevels.items() if door.name in v]
if (door.name not in self.doorAccess
or self.onHold
or self.formerMember
or not self.limitedOperations):
schedules = []
else:
schedules = self.schedules + doorLevels
dm = DoorMember(door,
forename=self.forename,
surname=self.surname,
membershipWorksID=self.membershipWorksID,
email=self.email,
phone=self.phone,
levels=self.levels + doorLevels,
doorAccess=self.doorAccess,
credentials=self.credentials,
schedules=schedules)
return dm
def __str__(self):
return super().__str__() + f"""OnHold? {self.onHold}
Limited Operations Access? {self.limitedOperations}
Former Member? {self.formerMember}
"""
class DoorMember(Member):
def __init__(self, door, *args, **kwargs):
super().__init__(*args, **kwargs)
self.door = door
@classmethod
def from_cardholder(cls, data, door):
ch = cls(door=door,
forename=data.get('forename', ""),
surname=data.get('surname', ""),
membershipWorksID=data.attrib.get('custom2', ""),
middleName=data.attrib.get('middleName', ""),
email=data.attrib.get('email', ""),
phone=data.attrib.get('phone', ""),
cardholderID=data.attrib['cardholderID'])
ch.credentials = set(
Credential(hex=(c.attrib['rawCardNumber']))
for c in data.findall('{*}Credential'))
ch.levels = data.attrib.get('custom1', "").split('|')
ch.schedules = [r.attrib['scheduleName']
for r in data.findall('{*}Role')]
return ch
def attribs(self):
return {"forename": self.forename,
"surname": self.surname,
"middleName": self.middleName,
"email": self.email,
"phone": self.phone,
"custom1": "|".join(self.levels).replace("&", "and"),
"custom2": self.membershipWorksID}
def make_schedules(self, schedulesMap):
roles = [
E.Role({"roleID": self.cardholderID,
"scheduleID": schedulesMap[schedule],
"resourceID": "0"})
for schedule in self.schedules]
return E.RoleSet({"action": "UD", "roleSetID": self.cardholderID},
E.Roles(*roles))
def make_credentials(self, newCredentials, cardFormats):
out = [
E.Credential(
{"formatName": str(credential.code[0]),
"cardNumber": str(credential.code[1]),
"formatID": cardFormats[str(credential.code[0])],
"isCard": "true",
"cardholderID": self.cardholderID})
for credential in newCredentials]
return E.Credentials({"action": "AD"}, *out)
def update_door(door, members):
cardFormats = door.get_cardFormats()
cardholders = {member.membershipWorksID: member
for member in [DoorMember.from_cardholder(ch, door)
for ch in door.get_cardholders()]}
schedulesMap = door.get_scheduleMap()
allCredentials = set(Credential(hex=c.attrib['rawCardNumber'])
for c in door.get_credentials())
# TODO: can I combine requests?
for membershipworksMember in members:
member = membershipworksMember.to_DoorMember(door)
# cardholder did not exist, so add them
if member.membershipWorksID not in cardholders:
print("- Adding Member {member.forename} {member.surname}:")
print(f" - {member.attribs()}")
resp = door.doXMLRequest(ROOT(
E.Cardholders(
{"action": "AD"},
E.Cardholder(member.attribs()))))
member.cardholderID = resp.find('{*}Cardholders/{*}Cardholder') \
.attrib["cardholderID"]
# create a dummy ch to force an update
# TODO: probably a cleaner way to do this
ch = copy.copy(member)
ch.schedules = []
ch.credentials = set()
# cardholder exists, compare contents
else:
ch = cardholders.pop(member.membershipWorksID)
member.cardholderID = ch.cardholderID
if member.attribs() != ch.attribs(): # update cardholder attributes
print(f"- Updating profile for {member.forename} {member.surname}")
print(f" - Old: {ch.attribs()}")
print(f" - New: {member.attribs()}")
door.doXMLRequest(ROOT(
E.Cardholders(
{"action": "UD", "cardholderID": member.cardholderID},
E.CardHolder(member.attribs()))))
if member.credentials != ch.credentials:
print(f"- Updating card for {member.forename} {member.surname}")
print(f" - {ch.credentials} -> {member.credentials}")
oldCards = ch.credentials
newCards = member.credentials
allNewCards = set(
card for m in members if m != membershipworksMember
for card in m.credentials)
# cards removed, and won't be reassigned to someone else
for card in (oldCards - newCards) - allNewCards:
door.doXMLRequest(ROOT(E.Credentials(
{"action": "UD",
"rawCardNumber": card.hex,
"isCard": "true"},
E.Credential({"cardholderID": ""}))))
if newCards - oldCards: # cards added
for card in newCards & allNewCards: # new card exists in another member
print([m for m in members
for card in m.credentials
if card in newCards])
raise Exception(f"Duplicate Card in input data! {card}")
# card existed in door, and needs to be reassigned
for card in newCards & allCredentials:
door.doXMLRequest(ROOT(E.Credentials(
{"action": "UD",
"rawCardNumber": card.hex,
"isCard": "true"},
E.Credential({"cardholderID": member.cardholderID}))))
# cards that never existed, and need to be created
if newCards - allCredentials:
door.doXMLRequest(ROOT(member.make_credentials(
newCards - allCredentials, cardFormats)))
if member.schedules != ch.schedules:
print("- Updating schedule for" +
f" {member.forename} {member.surname}:" +
f" {ch.schedules} -> {member.schedules}")
door.doXMLRequest(ROOT(member.make_schedules(schedulesMap)))
# TODO: delete cardholders that are no longer members?
def main():
membershipworks_attributes = \
"_id,nam,phn,eml,lvl,lbl,xws,xms,xsc,xas,xfd,xac,xcf,xeh,xse,xlo"
memberData = membershipworks.get_members(
['Members', 'CMS Staff', 'Misc. Access'], membershipworks_attributes)
members = [MembershipworksMember(m) for m in memberData]
formerMemberData = membershipworks.get_members(
['Former Members'], membershipworks_attributes)
formerMembers = [MembershipworksMember(m, formerMember=True)
for m in formerMemberData]
for formerMember in formerMembers:
member = next(
(m for m in members
if m.membershipWorksID == formerMember.membershipWorksID),
None)
# member exists in another folder
if member is not None:
member.formerMember = True
else: # member is only a former member
formerMember.formerMember = True
members.append(formerMember)
for door in doors.values():
print(door.name, door.ip)
update_door(door, members)
if __name__ == '__main__':
main()

67
events.py Executable file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
from collections import defaultdict
from lxml import etree
import os
import re
import requests
from common import *
def getStrings(door):
"""Parses out the message strings from source."""
r = requests.get('https://' + door.ip + '/html/en_EN/en_EN.js',
auth=requests.auth.HTTPDigestAuth(door.username, door.password),
verify=False)
regex = re.compile(r'([0-9]+)="([^"]*)')
strings = [regex.search(s) for s in r.text.split(';')
if s.startswith('localeStrings.eventDetails')]
print({int(g.group(1)): g.group(2) for g in strings})
def getMessages(door):
# get parameters for messages to get?
# honestly not really sure why this is required, their API is confusing
parXMLIn = E_plain.VertXMessage(
E.EventMessages({"action": "LR"}))
parXMLOut = door.doXMLRequest(parXMLIn)
etree.dump(parXMLOut)
if os.path.exists("logs/" + door.name + ".xml"):
# read last log
tree = etree.ElementTree(file="logs/" + door.name + ".xml")
root = tree.getroot()
recordCount = int(parXMLOut[0].attrib["historyRecordMarker"]) - \
int(root[0][0].attrib["recordMarker"])
else:
# first run for this door
root = None
recordCount = 1000
if recordCount == 0:
print("No records to get!")
return
print("Getting", recordCount, "records")
# get the actual messages
eventsXMLIn = E_plain.VertXMessage(
E.EventMessages({"action": "LR",
"recordCount": str(recordCount),
"historyRecordMarker": parXMLOut[0].attrib["historyRecordMarker"],
"historyTimestamp": parXMLOut[0].attrib["historyTimestamp"]}))
eventsXMLOut = door.doXMLRequest(eventsXMLIn)
#TODO: handle modeRecords=true
for index, event in enumerate(eventsXMLOut[0]):
event.attrib["recordMarker"] = str(int(parXMLOut[0].attrib["historyRecordMarker"]) - index)
if root is None:
tree = etree.ElementTree(eventsXMLOut)
else:
for event in reversed(eventsXMLOut[0]):
root[0].insert(0, event)
tree.write("logs/" + doorName + ".xml")
def main():
for door in doors.values():
getMessages(door)
if __name__ == '__main__':
main()

210
lib/MembershipWorks.py Normal file
View File

@ -0,0 +1,210 @@
import csv
from io import StringIO
import requests
BASE_URL = "https://api.membershipworks.com/v1/"
# extracted from `SF._is.crm` in https://cdn.membershipworks.com/all.js
CRM = {0: "Note",
4: "Profile Updated",
8: "Scheduled/Reminder Email",
9: "Renewal Notice",
10: "Join Date",
11: "Next Renewal Date",
12: "Membership Payment",
13: "Donation",
14: "Event Activity",
15: "Conversation",
16: "Contact Change",
17: "Label Change",
18: "Other Payment",
19: "Cart Payment",
20: "Payment Failed",
21: "Billing Updated",
22: "Form Checkout",
23: "Event Payment",
24: "Invoice",
25: "Invoice Payment",
26: "Renewal",
27: "Payment"}
# Types of fields, extracted from a html snippet in all.js + some guessing
typ = {
1: "Text input",
2: "Password", # inferred from data
3: "Simple text area",
4: "Rich text area",
7: "Address",
8: "Check box",
9: "Select",
11: "Display value stored in field (ie. read only)",
12: "Required waiver/terms"}
# more constants, this time extracted from the members csv export in all.js
staticFlags = {
"pos": {"lbl": "Position/relation (contacts)"},
"nte": {"lbl": "Admin note (contacts)"},
"pwd": {"lbl": "Password"},
"lgo": {"lbl": "Business card image URLs"},
"pfx": {"lbl": "Profile gallery image URLs"},
"lvl": {"lbl": "Membership levels"},
"aon": {"lbl": "Membership add-ons"},
"lbl": {"lbl": "Labels"},
"joi": {"lbl": "Join date"},
"end": {"lbl": "Renewal date"},
"spy": {"lbl": "Billing method"},
"rid": {"lbl": "Auto recurring billing ID"},
"ipa": {"lbl": "IP address"},
"_id": {"lbl": "Account ID"}
}
class MembershipWorksRemoteError(Exception):
def __init__(self, reason, r):
super().__init__(
f"Error when attempting {reason}: {r.status_code} {r.reason}\n{r.text}")
class MembershipWorks:
def __init__(self):
self.org_info = None
self.auth_token = None
self.org_num = None
def login(self, username, password):
"""Authenticate against the membershipworks api"""
r = requests.post(BASE_URL + 'usr',
data={"_st": "all",
"eml": username,
"org": "10000",
"pwd": password})
if r.status_code != 200 or 'SF' not in r.json():
raise MembershipWorksRemoteError('login', r)
self.org_info = r.json()
self.auth_token = self.org_info['SF']
self.org_num = self.org_info['org']
def _inject_auth(self, kwargs):
# TODO: should probably be a decorator or something
if self.auth_token is None:
raise RuntimeError('Not Logged in to MembershipWorks')
# add auth token to params
if 'params' not in kwargs:
kwargs['params'] = {}
kwargs['params']["SF"] = self.auth_token
def _get(self, *args, **kwargs):
self._inject_auth(kwargs)
# TODO: should probably do some error handling in here
return requests.get(*args, **kwargs)
def _post(self, *args, **kwargs):
self._inject_auth(kwargs)
# TODO: should probably do some error handling in here
return requests.post(*args, **kwargs)
def _all_fields(self):
"""Parse out a list of fields from the org data.
Is this terrible? Yes. Also, not dissimilar to how MW does it
in all.js.
"""
fields = staticFlags.copy()
# TODO: this will take the later option, if the same field
# used in mulitple places. I don't know which lbl is used for
# csv export
# anm: member signup, acc: member manage, adm: admin manage
for screen_type in ['anm', 'acc', 'adm']:
for box in self.org_info['tpl'][screen_type]:
for element in box['box']:
if (type(element['dat']) != str):
for field in element['dat']:
if '_id' in field:
if field['_id'] not in fields:
fields[field['_id']] = field
return fields
def _parse_flags(self):
"""Parse the flags out of the org data.
This is terrible, and there might be a better way to do this.
"""
ret = {"folders": {},
"levels": {},
"addons": {},
"labels": {}}
for dek in self.org_info['dek']:
# TODO: there must be a better way. this is stupid
if dek['dek'] == 1:
ret["folders"][dek['lbl']] = dek['_id']
elif 'cur' in dek:
ret["levels"][dek['lbl']] = dek['_id']
elif 'mux' in dek:
ret["addons"][dek['lbl']] = dek['_id']
else:
ret["labels"][dek['lbl']] = dek['_id']
return ret
def get_member_ids(self, folders):
folder_map = self._parse_flags()["folders"]
r = self._get(BASE_URL + "ylp", params={
"lbl": ",".join([folder_map[f] for f in folders]),
"org": self.org_num,
"var": "_id,nam,ctc"})
if r.status_code != 200 or 'usr' not in r.json():
raise MembershipWorksRemoteError('user listing', r)
# get list of member ID matching the search
return [user['uid'] for user in r.json()['usr']]
# TODO: has issues with aliasing header names:
# ex: "Personal Studio Space" Label vs Membership Addon/Field
def get_members(self, folders, columns):
""" Pull the members csv from the membershipworks api
folders: a list of the names of the folders to get
(see folder_map in this function for mapping to ids)
columns: which columns to get"""
ids = self.get_member_ids(folders)
# get members CSV
# TODO: maybe can just use previous get instead? would return JSON
r = self._post(BASE_URL + "csv",
data={"_rt": "946702800", # unknown
"mux": "", # unknown
"tid": ",".join(ids), # ids of members to get
"var": columns})
if r.status_code != 200:
raise MembershipWorksRemoteError('csv generation', r)
return list(csv.DictReader(StringIO(r.text)))
def get_transactions(self, start_date, end_date, json=False):
"""Get the transactions between start_date and end_date
Dates can be datetime.date or datetime.datetime
json gets a different version of the transactions list,
which contains a different set information
"""
r = self._get(BASE_URL + "csv",
params={'crm': '12,13,14,18,19', # transaction types, see CRM
**({'txl': ''} if json else {}),
'sdp': start_date.strftime('%s'),
'edp': end_date.strftime('%s')})
if r.status_code != 200:
raise MembershipWorksRemoteError('csv generation', r)
if json:
return r.json()
else:
return list(csv.DictReader(StringIO(r.text)))
def get_all_members(self):
"""Get all the data for all the members"""
folders = self._parse_flags()["folders"].keys()
fields = self._all_fields()
members = self.get_members(folders, ",".join(fields.keys()))
return members

View File

@ -3,7 +3,6 @@ import bitstring
# Reference for H10301 card format:
# https://www.hidglobal.com/sites/default/files/hid-understanding_card_data_formats-wp-en.pdf
class Credential:
def __init__(self, code=None, hex=None):
if code is None and hex is None:
@ -12,10 +11,8 @@ class Credential:
raise TypeError("Cannot set both code and hex for a Credential")
elif code is not None:
self.bits = bitstring.pack(
"0b000000, 0b0, uint:8=facility, uint:16=number, 0b0",
facility=code[0],
number=code[1],
)
'0b000000, 0b0, uint:8=facility, uint:16=number, 0b0',
facility=code[0], number=code[1])
self.bits[6] = self.bits[7:19].count(1) % 2 # even parity
self.bits[31] = not (self.bits[19:31].count(1) % 2) # odd parity
elif hex is not None:

178
lib/hid/DoorController.py Normal file
View File

@ -0,0 +1,178 @@
import csv
from io import StringIO
import urllib3
import requests
from lxml import etree
from lxml.builder import ElementMaker
E_plain = ElementMaker(nsmap={"hid": "http://www.hidglobal.com/VertX"})
E = ElementMaker(namespace="http://www.hidglobal.com/VertX",
nsmap={"hid": "http://www.hidglobal.com/VertX"})
E_corp = ElementMaker(namespace="http://www.hidcorp.com/VertX", #stupid
nsmap={"hid": "http://www.hidcorp.com/VertX"})
ROOT = E_plain.VertXMessage
fieldnames = "CardNumber,CardFormat,PinRequired,PinCode,ExtendedAccess,ExpiryDate,Forename,Initial,Surname,Email,Phone,Custom1,Custom2,Schedule1,Schedule2,Schedule3,Schedule4,Schedule5,Schedule6,Schedule7,Schedule8".split(",")
# TODO: where should this live?
# it's fine, ssl certs are for losers anyway
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class RemoteError(Exception):
def __init__(self, r):
super().__init__(
f"Door Updating Error: {r.status_code} {r.reason}\n{r.text}")
class DoorController():
def __init__(self, ip, username, password, name="", access=""):
self.ip = ip
self.username = username
self.password = password
self.name = name
self.access = access
def doImport(self, params=None, files=None):
"""Send a request to the door control import script"""
r = requests.post(
'https://' + self.ip + '/cgi-bin/import.cgi',
params=params,
files=files,
auth=requests.auth.HTTPDigestAuth(self.username, self.password),
timeout=60,
verify=False) # ignore insecure SSL
xml = etree.XML(r.content)
if r.status_code != 200 \
or len(xml.findall("{http://www.hidglobal.com/VertX}Error")) > 0:
raise RemoteError(r)
def doCSVImport(self, csv):
"""Do the CSV import procedure on a door control"""
self.doImport({"task": "importInit"})
self.doImport({"task": "importCardsPeople", "name": "cardspeopleschedule.csv"},
{"importCardsPeopleButton": ("cardspeopleschedule.csv", csv, 'text/csv')})
self.doImport({"task": "importDone"})
def doXMLRequest(self, xml, prefix=b'<?xml version="1.0" encoding="UTF-8"?>'):
if not isinstance(xml, bytes):
xml = etree.tostring(xml)
r = requests.get(
'https://' + self.ip + '/cgi-bin/vertx_xml.cgi',
params={'XML': prefix + xml},
auth=requests.auth.HTTPDigestAuth(self.username, self.password),
verify=False)
resp_xml = etree.XML(r.content)
# probably meed to be more sane about this
if r.status_code != 200 \
or len(resp_xml.findall("{*}Error")) > 0:
raise RemoteError(r)
return resp_xml
def get_scheduleMap(self):
schedules = self.doXMLRequest(
ROOT(E.Schedules({"action": "LR",
"recordOffset": "0",
"recordCount": "8"})))
return {fmt.attrib["scheduleName"]: fmt.attrib["scheduleID"]
for fmt in schedules[0]}
def get_schedules(self):
# TODO: might be able to do in one request
schedules = self.doXMLRequest(ROOT(
E.Schedules({"action": "LR"})))
etree.dump(schedules)
data = self.doXMLRequest(ROOT(
*[E.Schedules({"action": "LR",
"scheduleID": schedule.attrib["scheduleID"]})
for schedule in schedules[0]]))
return ROOT(E_corp.Schedules({"action": "AD"},
*[s[0] for s in data]))
def set_schedules(self, schedules):
# clear all people
outString = StringIO()
writer = csv.DictWriter(outString, fieldnames)
writer.writeheader()
writer.writerow({})
outString.seek(0)
self.doCSVImport(outString)
# clear all schedules
delXML = ROOT(
*[E.Schedules({"action": "DD", "scheduleID": str(ii)})
for ii in range(1, 8)])
try:
self.doXMLRequest(delXML)
except RemoteError:
# don't care about failure to delete, they probably just didn't exist
pass
# load new schedules
self.doXMLRequest(schedules)
def get_cardFormats(self):
cardFormats = self.doXMLRequest(
ROOT(E.CardFormats({"action": "LR",
"responseFormat": "expanded"})))
return {fmt[0].attrib["value"]: fmt.attrib["formatID"]
for fmt in cardFormats[0].findall('{*}CardFormat[{*}FixedField]')}
def set_cardFormat(self, formatName, templateID, facilityCode):
# TODO: add ability to delete formats
# delete example: <hid:CardFormats action="DD" formatID="7-1-244"/>
el = ROOT(
E.CardFormats({"action": "AD"},
E.CardFormat({"formatName": formatName,
"templateID": str(templateID)},
E.FixedField({"value": str(facilityCode)}))))
return self.doXMLRequest(el)
def get_records(self, req, count, params={}):
result = []
recordCount = 0
moreRecords = True
# note: all the "+/-1" bits are to work around a bug where the
# last returned entry is incomplete. There is probably a
# better way to do this, but for now I just get the last entry
# again in the next request. I suspect this probably ends
# poorly if the numbers line up poorly (ie an exact multiple
# of the returned record limit)
while moreRecords:
res = self.doXMLRequest(ROOT(
req({
"action": "LR",
"recordCount": str(count - recordCount + 1),
"recordOffset": str(recordCount - 1
if recordCount > 0 else 0),
**params
})))
result = result[:-1] + list(res[0])
recordCount += int(res[0].get('recordCount')) - 1
moreRecords = res[0].get('moreRecords') == 'true'
return result
def get_cardholders(self):
return self.get_records(E.Cardholders, 1000,
{"responseFormat": "expanded"})
def get_credentials(self):
return self.get_records(E.Credentials, 1000)
def get_lock(self):
el = ROOT(
E.Doors({"action": "LR", "responseFormat": "status"}))
xml = self.doXMLRequest(el)
relayState = xml.find('./{*}Doors/{*}Door').attrib['relayState']
return "unlocked" if relayState == "set" else "locked"
def set_lock(self, lock=True):
el = ROOT(
E.Doors({"action": "CM",
"command": "lockDoor" if lock else "unlockDoor"}))
return self.doXMLRequest(el)

View File

@ -1,265 +0,0 @@
import csv
from io import StringIO
import requests
import datetime
BASE_URL = "https://api.membershipworks.com"
# extracted from `SF._is.crm` in https://cdn.membershipworks.com/all.js
CRM = {
0: "Note",
4: "Profile Updated",
8: "Scheduled/Reminder Email",
9: "Renewal Notice",
10: "Join Date",
11: "Next Renewal Date",
12: "Membership Payment",
13: "Donation",
14: "Event Activity",
15: "Conversation",
16: "Contact Change",
17: "Label Change",
18: "Other Payment",
19: "Cart Payment",
20: "Payment Failed",
21: "Billing Updated",
22: "Form Checkout",
23: "Event Payment",
24: "Invoice",
25: "Invoice Payment",
26: "Renewal",
27: "Payment",
}
# Types of fields, extracted from a html snippet in all.js + some guessing
typ = {
1: "Text input",
2: "Password", # inferred from data
3: "Simple text area",
4: "Rich text area",
7: "Address",
8: "Check box",
9: "Select",
11: "Display value stored in field (ie. read only)",
12: "Required waiver/terms",
}
# more constants, this time extracted from the members csv export in all.js
staticFlags = {
"pos": {"lbl": "Position/relation (contacts)"},
"nte": {"lbl": "Admin note (contacts)"},
"pwd": {"lbl": "Password"},
"lgo": {"lbl": "Business card image URLs"},
"pfx": {"lbl": "Profile gallery image URLs"},
"lvl": {"lbl": "Membership levels"},
"aon": {"lbl": "Membership add-ons"},
"lbl": {"lbl": "Labels"},
"joi": {"lbl": "Join date"},
"end": {"lbl": "Renewal date"},
"spy": {"lbl": "Billing method"},
"rid": {"lbl": "Auto recurring billing ID"},
"ipa": {"lbl": "IP address"},
"_id": {"lbl": "Account ID"},
}
class MembershipWorksRemoteError(Exception):
def __init__(self, reason, r):
super().__init__(
f"Error when attempting {reason}: {r.status_code} {r.reason}\n{r.text}"
)
class MembershipWorks:
def __init__(self):
self.sess = requests.Session()
self.org_info = None
self.auth_token = None
self.org_num = None
def login(self, username, password):
"""Authenticate against the membershipworks api"""
r = self.sess.post(
BASE_URL + "/v2/account/session",
data={"eml": username, "pwd": password},
headers={"X-Org": "10000"},
)
if r.status_code != 200 or "SF" not in r.json():
raise MembershipWorksRemoteError("login", r)
self.org_info = r.json()
self.auth_token = self.org_info["SF"]
self.org_num = self.org_info["org"]
self.sess.headers.update(
{
"X-Org": str(self.org_num),
"X-Role": "admin",
"Authorization": "Bearer " + self.auth_token,
}
)
def _inject_auth(self, kwargs):
# TODO: should probably be a decorator or something
if self.auth_token is None:
raise RuntimeError("Not Logged in to MembershipWorks")
# add auth token to params
if "params" not in kwargs:
kwargs["params"] = {}
kwargs["params"]["SF"] = self.auth_token
def _get_v1(self, *args, **kwargs):
self._inject_auth(kwargs)
# TODO: should probably do some error handling in here
return requests.get(*args, **kwargs)
def _post_v1(self, *args, **kwargs):
self._inject_auth(kwargs)
# TODO: should probably do some error handling in here
return requests.post(*args, **kwargs)
def _all_fields(self):
"""Parse out a list of fields from the org data.
Is this terrible? Yes. Also, not dissimilar to how MW does it
in all.js.
"""
fields = staticFlags.copy()
# TODO: this will take the later option, if the same field
# used in mulitple places. I don't know which lbl is used for
# csv export
# anm: member signup, acc: member manage, adm: admin manage
for screen_type in ["anm", "acc", "adm"]:
for box in self.org_info["tpl"][screen_type]:
for element in box["box"]:
if type(element["dat"]) != str:
for field in element["dat"]:
if "_id" in field:
if field["_id"] not in fields:
fields[field["_id"]] = field
return fields
def _parse_flags(self):
"""Parse the flags out of the org data.
This is terrible, and there might be a better way to do this.
"""
ret = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
for dek in self.org_info["dek"]:
# TODO: there must be a better way. this is stupid
if dek["dek"] == 1:
ret["folders"][dek["lbl"]] = dek["did"]
elif "cur" in dek:
ret["levels"][dek["lbl"]] = dek["did"]
elif "mux" in dek:
ret["addons"][dek["lbl"]] = dek["did"]
else:
ret["labels"][dek["lbl"]] = dek["did"]
return ret
def get_member_ids(self, folders):
folder_map = self._parse_flags()["folders"]
r = self.sess.get(
BASE_URL + "/v2/accounts",
params={"dek": ",".join([folder_map[f] for f in folders])},
)
if r.status_code != 200 or "usr" not in r.json():
raise MembershipWorksRemoteError("user listing", r)
# get list of member ID matching the search
# dedup with set() to work around people with alt uids
# TODO: figure out why people have alt uids
return set(user["uid"] for user in r.json()["usr"])
# TODO: has issues with aliasing header names:
# ex: "Personal Studio Space" Label vs Membership Addon/Field
def get_members(self, folders, columns):
"""Pull the members csv from the membershipworks api
folders: a list of the names of the folders to get
(see folder_map in this function for mapping to ids)
columns: which columns to get"""
ids = self.get_member_ids(folders)
# get members CSV
# TODO: maybe can just use previous get instead? would return JSON
r = self._post_v1(
BASE_URL + "/v1/csv",
data={
"_rt": "946702800", # unknown
"mux": "", # unknown
"tid": ",".join(ids), # ids of members to get
"var": columns,
},
)
if r.status_code != 200:
raise MembershipWorksRemoteError("csv generation", r)
if r.text[0] == "\ufeff":
r.encoding = r.encoding + "-sig"
return list(csv.DictReader(StringIO(r.text)))
def get_transactions(self, start_date, end_date, json=False):
"""Get the transactions between start_date and end_date
Dates can be datetime.date or datetime.datetime
json gets a different version of the transactions list,
which contains a different set information
"""
r = self._get_v1(
BASE_URL + "/v1/csv",
params={
"crm": ",".join(str(k) for k in CRM.keys()),
**({"txl": ""} if json else {}),
"sdp": start_date.strftime("%s"),
"edp": end_date.strftime("%s"),
},
)
if r.status_code != 200:
raise MembershipWorksRemoteError("csv generation", r)
if json:
return r.json()
else:
if r.text[0] == "\ufeff":
r.encoding = r.encoding + "-sig"
return list(csv.DictReader(StringIO(r.text)))
def get_all_members(self):
"""Get all the data for all the members"""
folders = self._parse_flags()["folders"].keys()
fields = self._all_fields()
members = self.get_members(folders, ",".join(fields.keys()))
return members
def get_events_list(self, start_date: datetime.datetime):
"""Retrive a list of events since start_date"""
r = self.sess.get(
BASE_URL + "/v2/events",
params={
"sdp": start_date.strftime("%s"),
},
)
return r.json()
def get_event_by_eid(self, eid: str):
"""Retrieve a specific event by its event id (eid)"""
r = self.sess.get(
BASE_URL + "/v2/event",
params={"eid": eid},
)
return r.json()
def get_event_by_url(self, url: str):
"""Retrieve a specific event by its url"""
r = self.sess.get(
BASE_URL + "/v2/event",
params={"url": url},
)
return r.json()

View File

@ -1,36 +0,0 @@
from ruamel.yaml import YAML
from .hid.DoorController import DoorController
from .MembershipWorks import MembershipWorks
class Config:
def __init__(self, path="config.yaml"):
with open(path) as f:
self._data = YAML().load(f)
self.__dict__.update(self._data)
# lazy init, because this actually talks to an external server
self._membershipworks = None
@property
def doors(self):
return {
doorName: DoorController(
doorData["ip"],
self.DOOR_USERNAME,
self.DOOR_PASSWORD,
name=doorName,
access=doorData["access"],
)
for doorName, doorData in self.doorControllers.items()
}
@property
def membershipworks(self):
if not self._membershipworks:
self._membershipworks = MembershipWorks()
self._membershipworks.login(
self.MEMBERSHIPWORKS_USERNAME, self.MEMBERSHIPWORKS_PASSWORD
)
return self._membershipworks

View File

@ -1,370 +0,0 @@
#!/usr/bin/env python3
import copy
from .config import Config
from .hid.Credential import Credential
from .hid.DoorController import ROOT, E
class Member:
def __init__(
self,
forename="",
surname="",
membershipWorksID="",
middleName="",
email="",
phone="",
cardholderID=None,
doorAccess=[],
credentials=set(),
levels=[],
extraLevels=[],
schedules=[],
):
self.forename = forename
self.surname = surname
self.membershipWorksID = membershipWorksID
self.middleName = middleName
self.email = email
self.phone = phone
self.cardholderID = cardholderID
self.doorAccess = doorAccess
self.credentials = credentials
self.levels = levels
self.schedules = schedules
def __str__(self):
return f"""Name: {self.forename} | {self.middleName} | {self.surname}
MembershipWorks ID: {self.membershipWorksID}
Email: {self.email}
Phone: {self.phone}
Cardholder ID: {self.cardholderID}
doorAccess: {self.doorAccess}
Credentials: {self.credentials}
Levels: {self.levels}
Schedules: {self.schedules}
"""
class MembershipworksMember(Member):
def __init__(self, config, data, formerMember=False):
super().__init__(
data["First Name"],
data["Last Name"],
membershipWorksID=data["Account ID"],
email=data["Email"],
phone=data["Phone"],
)
if data["Access Card Number"] != "":
self.credentials = set(
[
Credential(
code=(
data["Access Card Facility Code"],
data["Access Card Number"],
)
)
]
)
else:
self.credentials = set()
self.onHold = (
data["Account on Hold"] != ""
or data["CMS Membership on hold"] == "CMS Membership on hold"
)
self.formerMember = formerMember
levels = {k: v for k, v in config.memberLevels.items() if data[k] == k}
self.levels = list(levels.keys())
self.schedules = list(levels.values())
self.extraLevels = {
schedule: sum(
(doors for prop, doors in props.items() if data[prop] == "Y"), []
)
for schedule, props in config.doorSpecificSchedules.items()
}
self.doorAccess = [
door
for door, doorData in config.doors.items()
if data["Access " + doorData.access + "?"] == "Y"
]
def to_DoorMember(self, door):
doorLevels = [k for k, v in self.extraLevels.items() if door.name in v]
schedules = []
if door.name in self.doorAccess and not self.onHold and not self.formerMember:
schedules = self.schedules + doorLevels
dm = DoorMember(
door,
forename=self.forename,
surname=self.surname,
membershipWorksID=self.membershipWorksID,
email=self.email,
phone=self.phone,
levels=self.levels + doorLevels,
doorAccess=self.doorAccess,
credentials=self.credentials,
schedules=schedules,
)
return dm
def __str__(self):
return (
super().__str__()
+ f"""OnHold? {self.onHold}
Former Member? {self.formerMember}
"""
)
class DoorMember(Member):
def __init__(self, door, *args, **kwargs):
super().__init__(*args, **kwargs)
self.door = door
@classmethod
def from_cardholder(cls, data, door):
ch = cls(
door=door,
forename=data.get("forename", ""),
surname=data.get("surname", ""),
membershipWorksID=data.attrib.get("custom2", ""),
middleName=data.attrib.get("middleName", ""),
email=data.attrib.get("email", ""),
phone=data.attrib.get("phone", ""),
cardholderID=data.attrib["cardholderID"],
)
ch.credentials = set(
Credential(hex=(c.attrib["rawCardNumber"]))
for c in data.findall("{*}Credential")
)
ch.levels = data.attrib.get("custom1", "").split("|")
ch.schedules = [r.attrib["scheduleName"] for r in data.findall("{*}Role")]
return ch
def attribs(self):
return {
"forename": self.forename,
"surname": self.surname,
"middleName": self.middleName,
"email": self.email,
"phone": self.phone,
"custom1": "|".join(self.levels).replace("&", "and"),
"custom2": self.membershipWorksID,
}
def make_schedules(self, schedulesMap):
roles = [
E.Role(
{
"roleID": self.cardholderID,
"scheduleID": schedulesMap[schedule],
"resourceID": "0",
}
)
for schedule in self.schedules
]
return E.RoleSet(
{"action": "UD", "roleSetID": self.cardholderID}, E.Roles(*roles)
)
def make_credentials(self, newCredentials, cardFormats):
out = [
E.Credential(
{
"formatName": str(credential.code[0]),
"cardNumber": str(credential.code[1]),
"formatID": cardFormats[str(credential.code[0])],
"isCard": "true",
"cardholderID": self.cardholderID,
}
)
for credential in newCredentials
]
return E.Credentials({"action": "AD"}, *out)
def update_door(door, members):
cardFormats = door.get_cardFormats()
cardholders = {
member.membershipWorksID: member
for member in [
DoorMember.from_cardholder(ch, door) for ch in door.get_cardholders()
]
}
schedulesMap = door.get_scheduleMap()
allCredentials = set(
Credential(hex=c.attrib["rawCardNumber"]) for c in door.get_credentials()
)
# TODO: can I combine requests?
for membershipworksMember in members:
member = membershipworksMember.to_DoorMember(door)
# cardholder did not exist, so add them
if member.membershipWorksID not in cardholders:
print("- Adding Member {member.forename} {member.surname}:")
print(f" - {member.attribs()}")
resp = door.doXMLRequest(
ROOT(E.Cardholders({"action": "AD"}, E.Cardholder(member.attribs())))
)
member.cardholderID = resp.find("{*}Cardholders/{*}Cardholder").attrib[
"cardholderID"
]
# create a dummy ch to force an update
# TODO: probably a cleaner way to do this
ch = copy.copy(member)
ch.schedules = []
ch.credentials = set()
# cardholder exists, compare contents
else:
ch = cardholders.pop(member.membershipWorksID)
member.cardholderID = ch.cardholderID
if member.attribs() != ch.attribs(): # update cardholder attributes
print(f"- Updating profile for {member.forename} {member.surname}")
print(f" - Old: {ch.attribs()}")
print(f" - New: {member.attribs()}")
door.doXMLRequest(
ROOT(
E.Cardholders(
{"action": "UD", "cardholderID": member.cardholderID},
E.CardHolder(member.attribs()),
)
)
)
if member.credentials != ch.credentials:
print(f"- Updating card for {member.forename} {member.surname}")
print(f" - {ch.credentials} -> {member.credentials}")
oldCards = ch.credentials
newCards = member.credentials
allNewCards = set(
card
for m in members
if m != membershipworksMember
for card in m.credentials
)
# cards removed, and won't be reassigned to someone else
for card in (oldCards - newCards) - allNewCards:
door.doXMLRequest(
ROOT(
E.Credentials(
{
"action": "UD",
"rawCardNumber": card.hex,
"isCard": "true",
},
E.Credential({"cardholderID": ""}),
)
)
)
if newCards - oldCards: # cards added
for card in newCards & allNewCards: # new card exists in another member
print(
[
m
for m in members
for card in m.credentials
if card in newCards
]
)
raise Exception(f"Duplicate Card in input data! {card}")
# card existed in door, and needs to be reassigned
for card in newCards & allCredentials:
door.doXMLRequest(
ROOT(
E.Credentials(
{
"action": "UD",
"rawCardNumber": card.hex,
"isCard": "true",
},
E.Credential({"cardholderID": member.cardholderID}),
)
)
)
# cards that never existed, and need to be created
if newCards - allCredentials:
door.doXMLRequest(
ROOT(
member.make_credentials(
newCards - allCredentials, cardFormats
)
)
)
if member.schedules != ch.schedules:
print(
"- Updating schedule for"
+ f" {member.forename} {member.surname}:"
+ f" {ch.schedules} -> {member.schedules}"
)
door.doXMLRequest(ROOT(member.make_schedules(schedulesMap)))
# TODO: delete cardholders that are no longer members?
def main():
config = Config()
membershipworks = config.membershipworks
membershipworks_attributes = (
"_id,nam,phn,eml,lvl,lbl,xws,xms,xsc,xas,xfd,xac,xcf,xeh,xse"
)
memberData = membershipworks.get_members(
["Members", "CMS Staff", "Misc. Access"], membershipworks_attributes
)
members = [MembershipworksMember(config, m) for m in memberData]
formerMemberData = membershipworks.get_members(
["Former Members"], membershipworks_attributes
)
formerMembers = [
MembershipworksMember(config, m, formerMember=True) for m in formerMemberData
]
for formerMember in formerMembers:
member = next(
(
m
for m in members
if m.membershipWorksID == formerMember.membershipWorksID
),
None,
)
# member exists in another folder
if member is not None:
member.formerMember = True
else: # member is only a former member
formerMember.formerMember = True
members.append(formerMember)
for door in config.doors.values():
print(door.name, door.ip)
update_door(door, members)
if __name__ == "__main__":
main()

View File

@ -1,223 +0,0 @@
import csv
from datetime import datetime
from io import StringIO
import requests
import urllib3
from lxml import etree
from lxml.builder import ElementMaker
E_plain = ElementMaker(nsmap={"hid": "http://www.hidglobal.com/VertX"})
E = ElementMaker(
namespace="http://www.hidglobal.com/VertX",
nsmap={"hid": "http://www.hidglobal.com/VertX"},
)
E_corp = ElementMaker(
namespace="http://www.hidcorp.com/VertX", # stupid
nsmap={"hid": "http://www.hidcorp.com/VertX"},
)
ROOT = E_plain.VertXMessage
fieldnames = "CardNumber,CardFormat,PinRequired,PinCode,ExtendedAccess,ExpiryDate,Forename,Initial,Surname,Email,Phone,Custom1,Custom2,Schedule1,Schedule2,Schedule3,Schedule4,Schedule5,Schedule6,Schedule7,Schedule8".split(
","
)
# TODO: where should this live?
# it's fine, ssl certs are for losers anyway
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class RemoteError(Exception):
def __init__(self, r):
super().__init__(f"Door Updating Error: {r.status_code} {r.reason}\n{r.text}")
class DoorController:
def __init__(self, ip, username, password, name="", access=""):
self.ip = ip
self.username = username
self.password = password
self.name = name
self.access = access
def doImport(self, params=None, files=None):
"""Send a request to the door control import script"""
r = requests.post(
"https://" + self.ip + "/cgi-bin/import.cgi",
params=params,
files=files,
auth=requests.auth.HTTPDigestAuth(self.username, self.password),
timeout=60,
verify=False,
) # ignore insecure SSL
xml = etree.XML(r.content)
if (
r.status_code != 200
or len(xml.findall("{http://www.hidglobal.com/VertX}Error")) > 0
):
raise RemoteError(r)
def doCSVImport(self, csv):
"""Do the CSV import procedure on a door control"""
self.doImport({"task": "importInit"})
self.doImport(
{"task": "importCardsPeople", "name": "cardspeopleschedule.csv"},
{"importCardsPeopleButton": ("cardspeopleschedule.csv", csv, "text/csv")},
)
self.doImport({"task": "importDone"})
def doXMLRequest(self, xml, prefix=b'<?xml version="1.0" encoding="UTF-8"?>'):
if not isinstance(xml, bytes):
xml = etree.tostring(xml)
r = requests.get(
"https://" + self.ip + "/cgi-bin/vertx_xml.cgi",
params={"XML": prefix + xml},
auth=requests.auth.HTTPDigestAuth(self.username, self.password),
verify=False,
)
resp_xml = etree.XML(r.content)
# probably meed to be more sane about this
if r.status_code != 200 or len(resp_xml.findall("{*}Error")) > 0:
raise RemoteError(r)
return resp_xml
def get_scheduleMap(self):
schedules = self.doXMLRequest(
ROOT(E.Schedules({"action": "LR", "recordOffset": "0", "recordCount": "8"}))
)
return {
fmt.attrib["scheduleName"]: fmt.attrib["scheduleID"] for fmt in schedules[0]
}
def get_schedules(self):
# TODO: might be able to do in one request
schedules = self.doXMLRequest(ROOT(E.Schedules({"action": "LR"})))
etree.dump(schedules)
data = self.doXMLRequest(
ROOT(
*[
E.Schedules(
{"action": "LR", "scheduleID": schedule.attrib["scheduleID"]}
)
for schedule in schedules[0]
]
)
)
return ROOT(E_corp.Schedules({"action": "AD"}, *[s[0] for s in data]))
def set_schedules(self, schedules):
# clear all people
outString = StringIO()
writer = csv.DictWriter(outString, fieldnames)
writer.writeheader()
writer.writerow({})
outString.seek(0)
self.doCSVImport(outString)
# clear all schedules
delXML = ROOT(
*[
E.Schedules({"action": "DD", "scheduleID": str(ii)})
for ii in range(1, 8)
]
)
try:
self.doXMLRequest(delXML)
except RemoteError:
# don't care about failure to delete, they probably just didn't exist
pass
# load new schedules
self.doXMLRequest(schedules)
def get_cardFormats(self):
cardFormats = self.doXMLRequest(
ROOT(E.CardFormats({"action": "LR", "responseFormat": "expanded"}))
)
return {
fmt[0].attrib["value"]: fmt.attrib["formatID"]
for fmt in cardFormats[0].findall("{*}CardFormat[{*}FixedField]")
}
def set_cardFormat(self, formatName, templateID, facilityCode):
# TODO: add ability to delete formats
# delete example: <hid:CardFormats action="DD" formatID="7-1-244"/>
el = ROOT(
E.CardFormats(
{"action": "AD"},
E.CardFormat(
{"formatName": formatName, "templateID": str(templateID)},
E.FixedField({"value": str(facilityCode)}),
),
)
)
return self.doXMLRequest(el)
def get_records(self, req, count, params={}, stopFunction=None):
result = []
recordCount = 0
moreRecords = True
# note: all the "+/-1" bits are to work around a bug where the
# last returned entry is incomplete. There is probably a
# better way to do this, but for now I just get the last entry
# again in the next request. I suspect this probably ends
# poorly if the numbers line up poorly (ie an exact multiple
# of the returned record limit)
while moreRecords and (stopFunction is None or stopFunction(result)):
res = self.doXMLRequest(
ROOT(
req(
{
"action": "LR",
"recordCount": str(count - recordCount + 1),
"recordOffset": str(
recordCount - 1 if recordCount > 0 else 0
),
**params,
}
)
)
)
result = result[:-1] + list(res[0])
recordCount += int(res[0].get("recordCount")) - 1
moreRecords = res[0].get("moreRecords") == "true"
return result
def get_cardholders(self):
return self.get_records(E.Cardholders, 1000, {"responseFormat": "expanded"})
def get_credentials(self):
return self.get_records(E.Credentials, 1000)
def get_events(self, threshold):
def event_newer_than_threshold(event):
return datetime.fromisoformat(event.attrib["timestamp"]) > threshold
def last_event_newer_than_threshold(events):
return (not events) or event_newer_than_threshold(events[-1])
return [
event
for event in self.get_records(
E.EventMessages, 10000, stopFunction=last_event_newer_than_threshold
)
if event_newer_than_threshold(event)
]
def get_lock(self):
el = ROOT(E.Doors({"action": "LR", "responseFormat": "status"}))
xml = self.doXMLRequest(el)
relayState = xml.find("./{*}Doors/{*}Door").attrib["relayState"]
return "unlocked" if relayState == "set" else "locked"
def set_lock(self, lock=True):
el = ROOT(
E.Doors({"action": "CM", "command": "lockDoor" if lock else "unlockDoor"})
)
return self.doXMLRequest(el)

View File

@ -1,112 +0,0 @@
#!/usr/bin/env python3
import re
from datetime import datetime
import requests
from lxml import etree
from peewee import (
BooleanField,
CharField,
CompositeKey,
DateTimeField,
IntegerField,
Model,
MySQLDatabase,
TextField,
)
from .config import Config
database = MySQLDatabase(None)
class HIDEvent(Model):
doorName = CharField()
timestamp = DateTimeField()
eventType = IntegerField()
readerAddress = IntegerField()
cardholderID = IntegerField(null=True)
commandStatus = BooleanField(null=True)
forename = TextField(null=True)
surname = TextField(null=True)
ioState = BooleanField(null=True)
newTime = DateTimeField(null=True)
oldTime = DateTimeField(null=True)
rawCardNumber = CharField(max_length=8, null=True)
class Meta:
primary_key = CompositeKey("doorName", "timestamp", "eventType")
database = database
def getStrings(door):
"""Parses out the message strings from source."""
r = requests.get(
"https://" + door.ip + "/html/en_EN/en_EN.js",
auth=requests.auth.HTTPDigestAuth(door.username, door.password),
verify=False,
)
regex = re.compile(r'([0-9]+)="([^"]*)')
strings = [
regex.search(s)
for s in r.text.split(";")
if s.startswith("localeStrings.eventDetails")
]
print({int(g.group(1)): g.group(2) for g in strings})
@database.atomic()
def getMessages(door):
last_event = (
HIDEvent.select(HIDEvent.timestamp)
.where(HIDEvent.doorName == door.name)
.order_by(HIDEvent.timestamp.desc())
.first()
)
if last_event is not None:
last_ts = last_event.timestamp
else:
last_ts = datetime(2010, 1, 1)
events = door.get_events(last_ts)
HIDEvent.insert_many(
[
{
# fill with None, then overwrite with real contents
**{k: None for k in HIDEvent._meta.fields.keys()},
**event.attrib,
"doorName": door.name,
}
for event in events
]
).on_conflict_ignore().execute()
return events
def dups(events):
timestamps = [e.attrib["timestamp"] for e in events]
dups = set([x for x in timestamps if timestamps.count(x) > 1])
for e in events:
if e.attrib["timestamp"] in dups:
etree.dump(e)
def main():
config = Config()
database.init(
**config.HID_DB,
**{
"charset": "utf8",
"sql_mode": "PIPES_AS_CONCAT",
"use_unicode": True,
}
)
HIDEvent.create_table()
for door in config.doors.values():
getMessages(door)
if __name__ == "__main__":
main()

View File

@ -1,248 +0,0 @@
from datetime import datetime
from peewee import (
BooleanField,
CompositeKey,
DateField,
DateTimeField,
DecimalField,
CharField,
FixedCharField,
ForeignKeyField,
Model,
MySQLDatabase,
TextField,
)
database = MySQLDatabase(None)
class BaseModel(Model):
_csv_headers_override = {}
_date_fields = {}
def insert_instance(self):
return self.insert(**self.__data__)
def upsert_instance(self):
return self.insert_instance().on_conflict(
action="update", preserve=list(self._meta.fields.values())
)
def magic_save(self):
if self._meta.primary_key is False:
self.get_or_create(**self.__data__)
else:
self.upsert_instance().execute()
@classmethod
def _headers_map(cls):
return {field.column_name: name for name, field in cls._meta.fields.items()}
@classmethod
def _remap_headers(cls, data):
# print(data)
hmap = cls._headers_map()
hmap.update(cls._csv_headers_override)
for k, v in data.items():
if k in hmap:
yield hmap.get(k), v
@classmethod
def from_csv_dict(cls, data):
data = data.copy()
# parse date fields to datetime objects
for field, fmt in cls._date_fields.items():
if data[field]:
data[field] = datetime.strptime(str(data[field]), fmt)
else:
# convert empty string to None to make NULL in SQL
data[field] = None
return cls(**dict(cls._remap_headers(data)))
class Meta:
database = database
# TODO: is this still a temporal table?
class Member(BaseModel):
uid = FixedCharField(24, primary_key=True)
year_of_birth = TextField(column_name="Year of Birth", null=True)
account_name = TextField(column_name="Account Name", null=True)
first_name = TextField(column_name="First Name", null=True)
last_name = TextField(column_name="Last Name", null=True)
phone = TextField(column_name="Phone", null=True)
email = TextField(column_name="Email", null=True)
address_street = TextField(column_name="Address (Street)", null=True)
address_city = TextField(column_name="Address (City)", null=True)
address_state_province = TextField(
column_name="Address (State/Province)", null=True
)
address_postal_code = TextField(column_name="Address (Postal Code)", null=True)
address_country = TextField(column_name="Address (Country)", null=True)
profile_description = TextField(column_name="Profile description", null=True)
website = TextField(column_name="Website", null=True)
fax = TextField(column_name="Fax", null=True)
contact_person = TextField(column_name="Contact Person", null=True)
password = TextField(column_name="Password", null=True)
position_relation = TextField(column_name="Position/relation", null=True)
parent_account_id = TextField(column_name="Parent Account ID", null=True)
gift_membership_purchased_by = TextField(
column_name="Gift Membership purchased by", null=True
)
purchased_gift_membership_for = TextField(
column_name="Purchased Gift Membership for", null=True
)
closet_storage = TextField(column_name="Closet Storage #", null=True)
storage_shelf = TextField(column_name="Storage Shelf #", null=True)
personal_studio_space = TextField(column_name="Personal Studio Space #", null=True)
access_permitted_shops_during_extended_hours = BooleanField(
column_name="Access Permitted Shops During Extended Hours?"
)
normal_access_permitted_during_covid19_limited_operations = BooleanField(
column_name="Normal Access Permitted During COVID-19 Limited Operations"
)
access_permitted_during_covid19_staffed_period_only = BooleanField(
column_name="Access Permitted During COVID-19 Staffed Period Only"
)
access_front_door_and_studio_space_during_extended_hours = BooleanField(
column_name="Access Front Door and Studio Space During Extended Hours?"
)
access_wood_shop = BooleanField(column_name="Access Wood Shop?")
access_metal_shop = BooleanField(column_name="Access Metal Shop?")
access_storage_closet = BooleanField(column_name="Access Storage Closet?")
access_studio_space = BooleanField(column_name="Access Studio Space?")
access_front_door = BooleanField(column_name="Access Front Door?")
access_card_number = TextField(column_name="Access Card Number", null=True)
access_card_facility_code = TextField(
column_name="Access Card Facility Code", null=True
)
auto_billing_id = TextField(column_name="Auto Billing ID", null=True)
billing_method = TextField(column_name="Billing Method", null=True)
renewal_date = DateField(column_name="Renewal Date", null=True)
join_date = DateField(column_name="Join Date", null=True)
admin_note = TextField(column_name="Admin note", null=True)
profile_gallery_image_url = TextField(
column_name="Profile gallery image URL", null=True
)
business_card_image_url = TextField(
column_name="Business card image URL", null=True
)
instagram = TextField(column_name="Instagram", null=True)
pinterest = TextField(column_name="Pinterest", null=True)
youtube = TextField(column_name="Youtube", null=True)
yelp = TextField(column_name="Yelp", null=True)
google = TextField(column_name="Google+", null=True)
bbb = TextField(column_name="BBB", null=True)
twitter = TextField(column_name="Twitter", null=True)
facebook = TextField(column_name="Facebook", null=True)
linked_in = TextField(column_name="LinkedIn", null=True)
do_not_show_street_address_in_profile = TextField(
column_name="Do not show street address in profile", null=True
)
do_not_list_in_directory = TextField(
column_name="Do not list in directory", null=True
)
how_did_you_hear = TextField(column_name="HowDidYouHear", null=True)
authorize_charge = TextField(column_name="authorizeCharge", null=True)
policy_agreement = TextField(column_name="policyAgreement", null=True)
waiver_form_signed_and_on_file_date = DateField(
column_name="Waiver form signed and on file date.", null=True
)
membership_agreement_signed_and_on_file_date = DateField(
column_name="Membership Agreement signed and on file date.", null=True
)
ip_address = TextField(column_name="IP Address", null=True)
audit_date = DateField(column_name="Audit Date", null=True)
agreement_version = TextField(column_name="Agreement Version", null=True)
paperwork_status = TextField(column_name="Paperwork status", null=True)
membership_agreement_dated = BooleanField(column_name="Membership agreement dated")
membership_agreement_acknowledgement_page_filled_out = BooleanField(
column_name="Membership Agreement Acknowledgement Page Filled Out"
)
membership_agreement_signed = BooleanField(
column_name="Membership Agreement Signed"
)
liability_form_filled_out = BooleanField(column_name="Liability Form Filled Out")
self_certify_essential_business = BooleanField(
column_name="selfCertifyEssentialBusiness"
)
accepted_covid19_policy = BooleanField(column_name="Accepted COVID-19 Policy")
_csv_headers_override = {
"Account ID": "uid",
"Please tell us how you heard about the Claremont MakerSpace and what tools or shops you are most excited to start using:": "how_did_you_hear",
"Yes - I authorize TwinState MakerSpaces, Inc. to charge my credit card for the membership and other options that I have selected.": "authorize_charge",
"I have read the Claremont MakerSpace Membership Agreement & Policies, and agree to all terms stated therein.": "policy_agreement",
"Access Permitted Using Membership Level Schedule During COVID-19 Limited Operations": "normal_access_permitted_during_covid19_limited_operations",
"I hereby certify that I am involved in Essential Business as defined by the State of New Hampshire and that I will follow the practices identified in the State of New Hampshire Exhibit C to Emergency Order #40 (Section E. Manufacturing).": "self_certify_essential_business",
"I have read, and agree to abide by the terms, conditions, policies and procedures contained in the Claremont MakerSpace COVID-19 Policies and Procedures.": "accepted_covid19_policy",
}
_date_fields = {
"Join Date": "%b %d, %Y",
"Renewal Date": "%b %d, %Y",
"Audit Date": "%m/%d/%Y",
"Membership Agreement signed and on file date.": "%m/%d/%Y",
"Waiver form signed and on file date.": "%m/%d/%Y",
}
class Meta:
table_name = "members"
class Flag(BaseModel):
id = FixedCharField(24, primary_key=True)
name = TextField(null=True)
type = CharField(6)
class MemberFlag(BaseModel):
uid = ForeignKeyField(Member, column_name="uid", backref="flags")
flag_id = ForeignKeyField(Flag, backref="members")
class Meta:
primary_key = CompositeKey("flag_id", "uid")
class Transaction(BaseModel):
sid = FixedCharField(27, null=True)
uid = ForeignKeyField(Member, column_name="uid", backref="transactions", null=True)
timestamp = DateTimeField()
type = TextField(null=True)
sum = DecimalField(13, 4, null=True)
fee = DecimalField(13, 4, null=True)
event_id = TextField(null=True)
for_ = TextField(column_name="For", null=True)
items = TextField(column_name="Items", null=True)
discount_code = TextField(column_name="Discount Code", null=True)
note = TextField(column_name="Note", null=True)
name = TextField(column_name="Name", null=True)
contact_person = TextField(column_name="Contact Person", null=True)
full_address = TextField(column_name="Full Address", null=True)
street = TextField(column_name="Street", null=True)
city = TextField(column_name="City", null=True)
state_province = TextField(column_name="State/Province", null=True)
postal_code = TextField(column_name="Postal Code", null=True)
country = TextField(column_name="Country", null=True)
phone = TextField(column_name="Phone", null=True)
email = TextField(column_name="Email", null=True)
_csv_headers_override = {
"_dp": "timestamp",
"Transaction Type": "type",
"Event/Form Name": "for_",
}
@classmethod
def from_csv_dict(cls, data):
txn = data.copy()
# can't use '%s' format string, have to use the special function
txn["_dp"] = datetime.fromtimestamp(txn["_dp"])
return super().from_csv_dict(txn)
class Meta:
table_name = "transactions"
primary_key = False

View File

@ -1,96 +0,0 @@
#!/usr/bin/env python3
from datetime import datetime
from .config import Config
from .mw_models import Member, Flag, MemberFlag, Transaction, database
@database.atomic()
def do_import(config):
membershipworks = config.membershipworks
print("Creating tables")
database.create_tables([Member, Flag, MemberFlag, Transaction])
print("Updating flags (labels, levels, and addons)")
flags = membershipworks._parse_flags()
Flag.insert_many(
[
{"id": v, "name": k, "type": typ[:-1]}
for typ, flags_of_type in flags.items()
for k, v in flags_of_type.items()
]
).on_conflict(action="update", preserve=[Flag.name, Flag.type]).execute()
print("Getting folder membership...")
folders = {
folder_id: membershipworks.get_member_ids([folder_name])
for folder_name, folder_id in membershipworks._parse_flags()["folders"].items()
}
print("Getting/Updating members...")
members = membershipworks.get_all_members()
for m in members:
# replace flags by booleans
for flag in [dek["lbl"] for dek in membershipworks.org_info["dek"]]:
if flag in m:
m[flag] = m[flag] == flag
for field_id, field in membershipworks._all_fields().items():
# convert checkboxes to real booleans
if field.get("typ") == 8 and field["lbl"] in m: # check box
m[field["lbl"]] = True if m[field["lbl"]] == "Y" else False
for member in members:
# create/update member
Member.from_csv_dict(member).magic_save()
# update member's flags
for type, flags in membershipworks._parse_flags().items():
for flag, id in flags.items():
ml = MemberFlag(uid=member["Account ID"], flag_id=id)
if (type == "folders" and member["Account ID"] in folders[id]) or (
type != "folders" and member[flag]
):
ml.magic_save()
else:
ml.delete_instance()
print("Getting/Updating transactions...")
# Deduping these is hard, so just recreate the data every time
Transaction.truncate_table()
now = datetime.now()
start_date = datetime(2010, 1, 1)
transactions_csv = membershipworks.get_transactions(start_date, now)
transactions_json = membershipworks.get_transactions(start_date, now, json=True)
# this is terrible, but as long as the dates are the same, should be fiiiine
transactions = [{**j, **v} for j, v in zip(transactions_csv, transactions_json)]
assert all(
[
t["Account ID"] == t.get("uid", "") and t["Payment ID"] == t.get("sid", "")
for t in transactions
]
)
for transaction in transactions:
Transaction.from_csv_dict(transaction).magic_save()
# TODO: folders, levels, addons
def main():
config = Config()
database.init(
**config.MEMBERSHIPWORKS_DB,
**{
"charset": "utf8",
"sql_mode": "PIPES_AS_CONCAT",
"use_unicode": True,
}
)
do_import(config)
if __name__ == "__main__":
main()

View File

@ -1,131 +0,0 @@
#!/usr/bin/env python3
import asyncio
import random
import re
import string
from udm_rest_client.udm import UDM
from udm_rest_client.exceptions import NoObject, UdmError
from .config import Config
USER_BASE = "cn=users,dc=sawtooth,dc=claremontmakerspace,dc=org"
GROUP_BASE = "cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
GROUPS_REGEX = "|".join(
["Certified: .*", "Access .*\?", "CMS .*", "Volunteer: .*", "Database .*"]
)
RAND_PW_LEN = 20
# From an API error message:
# A group name must start and end with a letter, number or underscore.
# In between additionally spaces, dashes and dots are allowed.
def sanitize_group_name(name):
sanitized_body = re.sub(r"[^0-9A-Za-z_ -.]", ".", name)
sanitized_start_end = re.sub("^[^0-9A-Za-z_]|[^0-9A-Za-z_]$", "_", sanitized_body)
return "MW_" + sanitized_start_end
# From an API error message: "Username must only contain numbers, letters and dots!"
def sanitize_user_name(name):
return re.sub(r"[^0-9a-z.]", ".", name.lower()).strip(".")
async def make_groups(group_mod, members):
existing_group_names = [g.props.name async for g in group_mod.search()]
groups = [
sanitize_group_name(group_name)
for group_name in members[0].keys()
if re.match(GROUPS_REGEX, group_name) is not None
]
for group_name in groups:
if group_name not in existing_group_names:
group = await group_mod.new()
group.props.name = group_name
await group.save()
async def _main():
config = Config()
members = config.membershipworks.get_members(
["Members", "CMS Staff"], "lvl,phn,eml,lbl,nam,end,_id"
)
async with UDM(**config.UCS) as udm:
user_mod = udm.get("users/user")
group_mod = udm.get("groups/group")
await make_groups(group_mod, members)
for member in members:
username = sanitize_user_name(member["Account Name"])
try: # try to get an existing user to update
user = await user_mod.get(f"uid={username},{USER_BASE}")
except NoObject: # create a new user
# TODO: search by employeeNumber and rename users when needed
user = await user_mod.new()
# set a random password and ensure it is changed at next login
user.props.password = "".join(
random.choice(string.ascii_letters + string.digits)
for x in range(0, RAND_PW_LEN)
)
user.props.pwdChangeNextLogin = True
user.props.update(
{
"title": "", # Title
"firstname": member["First Name"],
"lastname": member["Last Name"], # (c)
"username": username, # (cmr)
"description": "", # Description
# "password": "", # (c) Password
# "mailPrimaryAddress": member["Email"], # Primary e-mail address
# "displayName": "", # Display name
# "birthday": "", # Birthdate
# "jpegPhoto": "", # Picture of the user (JPEG format)
"employeeNumber": member["Account ID"],
# "employeeType": "", # Employee type
"homedrive": "H:", # Windows home drive
"sambahome": "\\\\ucs\\" + username, # Windows home path
"profilepath": "%LOGONSERVER%\\%USERNAME%\\windows-profiles\\default", # Windows profile directory
"disabled": member["Account on Hold"] != "",
# "userexpiry": member["Renewal Date"],
# "pwdChangeNextLogin": "1", # User has to change password on next login
# "sambaLogonHours": "", # Permitted times for Windows logins
"e-mail": [member["Email"]], # ([]) E-mail address
"phone": [member["Phone"]], # Telephone number
# "PasswordRecoveryMobile": member["Phone"], # Mobile phone number
"PasswordRecoveryEmail": member["Email"],
}
)
new_groups = [
"cn=" + sanitize_group_name(group) + "," + GROUP_BASE
for group, value in member.items()
if re.match(GROUPS_REGEX, group) is not None and value != ""
]
# groups not from this script
other_old_groups = [
g for g in user.props.groups if not g[3:].startswith("MW_")
]
user.props.groups = other_old_groups + new_groups
try:
await user.save()
except UdmError:
print("Failed to save user", username)
print(user.props)
raise
def main():
asyncio.run(_main())
if __name__ == "__main__":
main()

View File

@ -1,76 +0,0 @@
#!/usr/bin/env python3
from datetime import datetime
from .config import Config
from .MembershipWorks import MembershipWorks
def format_event(membershipworks: MembershipWorks, event):
event_details = membershipworks.get_event_by_eid(event["eid"])
url = (
"https://claremontmakerspace.org/events/#!event/register/"
+ event_details["url"]
)
if "lgo" in event_details:
img = f"""<img class="aligncenter" width="500" height="500" src="{event_details['lgo']['l']}">"""
else:
img = ""
# print(json.dumps(event_details))
return f"""<h2 style="text-align: center;">
<a href="{url}">
{img}
{event_details['ttl']}
</a>
</h2>
<div>{event_details['szp']} &mdash; {event_details['ezp']}</div>
<div>
{event_details['dtl']}
</div>
<a href="{url}">Register for this class now!</a>"""
def main():
config = Config()
membershipworks = config.membershipworks
events = membershipworks.get_events_list(datetime.now())
if "error" in events:
print("Error:", events["error"])
return
events_list = "\n<hr />\n\n".join(
format_event(membershipworks, event)
for event in events["evt"]
if event["ttl"] != "[TEMPLATE FOR COPYING]"
)
header = """<p><img class="aligncenter size-medium wp-image-2319" src="https://claremontmakerspace.org/wp-content/uploads/2019/03/CMS-Logo-b-y-g-300x168.png" alt="" width="300" height="168" /></a></p>
<p>Greetings Upper Valley Makers:</p>
<p>We have an exciting list of upcoming classes at the Claremont MakerSpace that we think might interest you.</p>
<strong>For most classes and events, CMS MEMBERSHIP IS NOT REQUIRED.</strong> That said, members receive a discount on registration and there are some classes/events that are for members only (this will be clearly noted in the event description).
<strong>Class policies</strong> (liability waiver, withdrawal, cancellation, etc.) can be found <a href="https://claremontmakerspace.org/class-policies/" data-wpel-link="internal">here</a>.
<strong>Please note: </strong>The Claremont MakerSpace currently requires masks for all visitors and members.
<strong>Instructors:</strong> Interested in teaching a class at CMS? Please fill out our <a href="https://docs.google.com/forms/d/e/1FAIpQLSdJyEVRJxzIczG784VkOm_DsNyv-VXRXYzlis8qlMdEOvHGpQ/viewform" target="_blank" rel="noreferrer noopener external" data-wpel-link="external">Class Proposal Form</a><a href="https://docs.google.com/forms/d/e/1FAIpQLSdJyEVRJxzIczG784VkOm_DsNyv-VXRXYzlis8qlMdEOvHGpQ/viewform" target="_blank" rel="noreferrer noopener external" data-wpel-link="external">.</a>
<strong>Tours:</strong> Want to see what the Claremont MakerSpace is all about? Tours are by appointment only due to COVID-19 restrictions. <a href="https://tickets.claremontmakerspace.org/open.php" target="_blank" rel="noreferrer noopener external" data-wpel-link="external">Contact Us</a> to schedule your tour where you can learn about all the awesome tools that the CMS offers access to, as well as how membership, classes, and studio spaces work.
<hr />
"""
footer = """
<hr />
<div>Happy Makin!</div>
<div>We are grateful for all of the public support that our 501(c)(3), non-profit organization receives. If youd like to make a donation,please visit the <a href="https://claremontmakerspace.org/support/"><strong>Support Us page</strong></a> of our website.</div>
</div>
"""
print(header, events_list, footer)
if __name__ == "__main__":
main()

70
membershipViewer.py Normal file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env python3
import re
import http
from flask import Flask, render_template, request
app = Flask(__name__)
from common import doors, membershipworks
def parse_list(member, regex):
data_list = []
for key, value in member.items():
match = re.match(regex, key)
if match is not None and value != '':
data_list.append(match.group(1))
return ", ".join(data_list)
def parse_members(members):
data = []
for member in members:
props = {
'Name': member['Account Name'],
'Renewal Date': member['Renewal Date'],
'Card Number': member['Access Card Facility Code'] + '-' \
+ member['Access Card Number'],
'Account on Hold': "Yes" if member['Account on Hold'] != '' else "No" }
props['Certifications'] = parse_list(member, 'Certified: (.*)')
props['Door Access'] = parse_list(member, 'Access (.*)\?')
props['Memebership Level'] = parse_list(member, 'CMS (.*)')
data.append(props)
return data
@app.route("/")
def main():
# maybe not now: membership agreement signed
# TODO: renewal date check
term = request.args.get('term', '')
if len(term) < 3:
return render_template("members.html",
error="Enter at least 3 characters to search")
data = membershipworks.get_members(
['members', 'staff'],
"lvl,xws,xms,xsc,xas,xfd,xac,phn,eml,lbl,xcf,nam,end")
members = parse_members(data)
members = [member for member in members
if term.lower() in member['Name'].lower()]
headers = ['Name', 'Certifications', 'Door Access', 'Memebership Level',
'Card Number', 'Renewal Date', 'Account on Hold']
if len(members) > 4:
return render_template(
"members.html", error="Too many results, please be more specific.")
return render_template("members.html", headers=headers, members=members)
@app.route('/frontDoor/<lock>', methods=['POST'])
def unlockLockDoor(lock):
doors['Front Door'].lockOrUnlockDoor(lock != 'unlock')
return ('', http.HTTPStatus.NO_CONTENT)
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0')

955
poetry.lock generated
View File

@ -1,955 +0,0 @@
[[package]]
name = "aiohttp"
version = "3.8.1"
description = "Async http client/server framework (asyncio)"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
aiosignal = ">=1.1.2"
async-timeout = ">=4.0.0a3,<5.0"
asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""}
attrs = ">=17.3.0"
charset-normalizer = ">=2.0,<3.0"
frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["aiodns", "brotli", "cchardet"]
[[package]]
name = "aiosignal"
version = "1.2.0"
description = "aiosignal: a list of registered asynchronous callbacks"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "async-property"
version = "0.2.1"
description = "Python decorator for async properties."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "async-timeout"
version = "4.0.2"
description = "Timeout context manager for asyncio programs"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""}
[[package]]
name = "asynctest"
version = "0.13.0"
description = "Enhance the standard unittest package with features for testing asyncio libraries"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "attrs"
version = "21.4.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "bitstring"
version = "3.1.9"
description = "Simple construction, analysis and modification of binary data."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "black"
version = "22.3.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2022.5.18.1"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "charset-normalizer"
version = "2.0.12"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "colorama"
version = "0.4.4"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "docker"
version = "5.0.3"
description = "A Python library for the Docker Engine API."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pywin32 = {version = "227", markers = "sys_platform == \"win32\""}
requests = ">=2.14.2,<2.18.0 || >2.18.0"
websocket-client = ">=0.32.0"
[package.extras]
ssh = ["paramiko (>=2.4.2)"]
tls = ["pyOpenSSL (>=17.5.0)", "cryptography (>=3.4.7)", "idna (>=2.0.0)"]
[[package]]
name = "frozenlist"
version = "1.3.0"
description = "A list-like structure which implements collections.abc.MutableSequence"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "idna"
version = "3.3"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "4.11.4"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
perf = ["ipython"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
[[package]]
name = "isort"
version = "5.10.1"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
[package.extras]
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
requirements_deprecated_finder = ["pipreqs", "pip-api"]
colors = ["colorama (>=0.4.3,<0.5.0)"]
plugins = ["setuptools"]
[[package]]
name = "lxml"
version = "4.8.0"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html5 = ["html5lib"]
htmlsoup = ["beautifulsoup4"]
source = ["Cython (>=0.29.7)"]
[[package]]
name = "multidict"
version = "6.0.2"
description = "multidict implementation"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "mysqlclient"
version = "2.1.0"
description = "Python interface to MySQL"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "pathspec"
version = "0.9.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "peewee"
version = "3.14.10"
description = "a little orm"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "platformdirs"
version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
[[package]]
name = "pywin32"
version = "227"
description = "Python for Window Extensions"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "requests"
version = "2.27.1"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "ruamel.yaml"
version = "0.17.21"
description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order"
category = "main"
optional = false
python-versions = ">=3"
[package.dependencies]
"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""}
[package.extras]
docs = ["ryd"]
jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"]
[[package]]
name = "ruamel.yaml.clib"
version = "0.2.6"
description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "typed-ast"
version = "1.5.4"
description = "a fork of Python 2 and 3 ast modules with type comment support"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "typing-extensions"
version = "4.2.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "udm-rest-client"
version = "1.0.7"
description = "Python library to interact with the Univention UDM REST API. Implements the simple Python UDM API."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
aiohttp = ">=3.8.1,<4"
async-property = ">=0.2.1,<0.3"
click = ">=7,<9"
docker = ">=5.0.3,<6"
requests = ">=2.26,<3"
[[package]]
name = "urllib3"
version = "1.26.9"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "websocket-client"
version = "1.3.2"
description = "WebSocket client for Python with low level API options"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"]
optional = ["python-socks", "wsaccel"]
test = ["websockets"]
[[package]]
name = "yarl"
version = "1.7.2"
description = "Yet another URL library"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
[[package]]
name = "zipp"
version = "3.8.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "ad657c4c736847ce55f3049ce04eb4a8c502914e083004842ae1f4091dd44c66"
[metadata.files]
aiohttp = [
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"},
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"},
{file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"},
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"},
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"},
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"},
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"},
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"},
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"},
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"},
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"},
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"},
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"},
{file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"},
{file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"},
{file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"},
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"},
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"},
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"},
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"},
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"},
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"},
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"},
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"},
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"},
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"},
{file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"},
{file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"},
{file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"},
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"},
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"},
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"},
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"},
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"},
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"},
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"},
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"},
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"},
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"},
{file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"},
{file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"},
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"},
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"},
{file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"},
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"},
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"},
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"},
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"},
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"},
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"},
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"},
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"},
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"},
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"},
{file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"},
{file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"},
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"},
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"},
{file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"},
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"},
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"},
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"},
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"},
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"},
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"},
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"},
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"},
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"},
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"},
{file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"},
{file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"},
{file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"},
]
aiosignal = [
{file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
{file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
]
async-property = [
{file = "async_property-0.2.1-py2.py3-none-any.whl", hash = "sha256:f1f105009a6216ed9a13031aa13632754ed8a5c2e329fb8f9f2082d83529eacd"},
{file = "async_property-0.2.1.tar.gz", hash = "sha256:53826fd45a67d7d6cca3d7abbc0e8ba951f7c7618c830021fbd3675979b0b67d"},
]
async-timeout = [
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
]
asynctest = [
{file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"},
{file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"},
]
attrs = [
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
bitstring = [
{file = "bitstring-3.1.9-py2-none-any.whl", hash = "sha256:e3e340e58900a948787a05e8c08772f1ccbe133f6f41fe3f0fa19a18a22bbf4f"},
{file = "bitstring-3.1.9-py3-none-any.whl", hash = "sha256:0de167daa6a00c9386255a7cac931b45e6e24e0ad7ea64f1f92a64ac23ad4578"},
{file = "bitstring-3.1.9.tar.gz", hash = "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7"},
]
black = [
{file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"},
{file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"},
{file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"},
{file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"},
{file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"},
{file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"},
{file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"},
{file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"},
{file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"},
{file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"},
{file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"},
{file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"},
{file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"},
{file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"},
{file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"},
{file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"},
{file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"},
{file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"},
{file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"},
{file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"},
{file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"},
{file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"},
{file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"},
]
certifi = [
{file = "certifi-2022.5.18.1-py3-none-any.whl", hash = "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a"},
{file = "certifi-2022.5.18.1.tar.gz", hash = "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7"},
]
charset-normalizer = [
{file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
{file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
docker = [
{file = "docker-5.0.3-py2.py3-none-any.whl", hash = "sha256:7a79bb439e3df59d0a72621775d600bc8bc8b422d285824cb37103eab91d1ce0"},
{file = "docker-5.0.3.tar.gz", hash = "sha256:d916a26b62970e7c2f554110ed6af04c7ccff8e9f81ad17d0d40c75637e227fb"},
]
frozenlist = [
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"},
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"},
{file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"},
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"},
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"},
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"},
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"},
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"},
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"},
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"},
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"},
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"},
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"},
{file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"},
{file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"},
{file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"},
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"},
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"},
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"},
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"},
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"},
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"},
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"},
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"},
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"},
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"},
{file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"},
{file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"},
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"},
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"},
{file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"},
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"},
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"},
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"},
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"},
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"},
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"},
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"},
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"},
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"},
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"},
{file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"},
{file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"},
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"},
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"},
{file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"},
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"},
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"},
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"},
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"},
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"},
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"},
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"},
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"},
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"},
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"},
{file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"},
{file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"},
{file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
importlib-metadata = [
{file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"},
{file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"},
]
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
lxml = [
{file = "lxml-4.8.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e1ab2fac607842ac36864e358c42feb0960ae62c34aa4caaf12ada0a1fb5d99b"},
{file = "lxml-4.8.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28d1af847786f68bec57961f31221125c29d6f52d9187c01cd34dc14e2b29430"},
{file = "lxml-4.8.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b92d40121dcbd74831b690a75533da703750f7041b4bf951befc657c37e5695a"},
{file = "lxml-4.8.0-cp27-cp27m-win32.whl", hash = "sha256:e01f9531ba5420838c801c21c1b0f45dbc9607cb22ea2cf132844453bec863a5"},
{file = "lxml-4.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:6259b511b0f2527e6d55ad87acc1c07b3cbffc3d5e050d7e7bcfa151b8202df9"},
{file = "lxml-4.8.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1010042bfcac2b2dc6098260a2ed022968dbdfaf285fc65a3acf8e4eb1ffd1bc"},
{file = "lxml-4.8.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fa56bb08b3dd8eac3a8c5b7d075c94e74f755fd9d8a04543ae8d37b1612dd170"},
{file = "lxml-4.8.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:31ba2cbc64516dcdd6c24418daa7abff989ddf3ba6d3ea6f6ce6f2ed6e754ec9"},
{file = "lxml-4.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:31499847fc5f73ee17dbe1b8e24c6dafc4e8d5b48803d17d22988976b0171f03"},
{file = "lxml-4.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5f7d7d9afc7b293147e2d506a4596641d60181a35279ef3aa5778d0d9d9123fe"},
{file = "lxml-4.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a3c5f1a719aa11866ffc530d54ad965063a8cbbecae6515acbd5f0fae8f48eaa"},
{file = "lxml-4.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6268e27873a3d191849204d00d03f65c0e343b3bcb518a6eaae05677c95621d1"},
{file = "lxml-4.8.0-cp310-cp310-win32.whl", hash = "sha256:330bff92c26d4aee79c5bc4d9967858bdbe73fdbdbacb5daf623a03a914fe05b"},
{file = "lxml-4.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b2582b238e1658c4061ebe1b4df53c435190d22457642377fd0cb30685cdfb76"},
{file = "lxml-4.8.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a2bfc7e2a0601b475477c954bf167dee6d0f55cb167e3f3e7cefad906e7759f6"},
{file = "lxml-4.8.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a1547ff4b8a833511eeaceacbcd17b043214fcdb385148f9c1bc5556ca9623e2"},
{file = "lxml-4.8.0-cp35-cp35m-win32.whl", hash = "sha256:a9f1c3489736ff8e1c7652e9dc39f80cff820f23624f23d9eab6e122ac99b150"},
{file = "lxml-4.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:530f278849031b0eb12f46cca0e5db01cfe5177ab13bd6878c6e739319bae654"},
{file = "lxml-4.8.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:078306d19a33920004addeb5f4630781aaeabb6a8d01398045fcde085091a169"},
{file = "lxml-4.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:86545e351e879d0b72b620db6a3b96346921fa87b3d366d6c074e5a9a0b8dadb"},
{file = "lxml-4.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24f5c5ae618395ed871b3d8ebfcbb36e3f1091fd847bf54c4de623f9107942f3"},
{file = "lxml-4.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bbab6faf6568484707acc052f4dfc3802bdb0cafe079383fbaa23f1cdae9ecd4"},
{file = "lxml-4.8.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7993232bd4044392c47779a3c7e8889fea6883be46281d45a81451acfd704d7e"},
{file = "lxml-4.8.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d6483b1229470e1d8835e52e0ff3c6973b9b97b24cd1c116dca90b57a2cc613"},
{file = "lxml-4.8.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ad4332a532e2d5acb231a2e5d33f943750091ee435daffca3fec0a53224e7e33"},
{file = "lxml-4.8.0-cp36-cp36m-win32.whl", hash = "sha256:db3535733f59e5605a88a706824dfcb9bd06725e709ecb017e165fc1d6e7d429"},
{file = "lxml-4.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5f148b0c6133fb928503cfcdfdba395010f997aa44bcf6474fcdd0c5398d9b63"},
{file = "lxml-4.8.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8a31f24e2a0b6317f33aafbb2f0895c0bce772980ae60c2c640d82caac49628a"},
{file = "lxml-4.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:719544565c2937c21a6f76d520e6e52b726d132815adb3447ccffbe9f44203c4"},
{file = "lxml-4.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:c0b88ed1ae66777a798dc54f627e32d3b81c8009967c63993c450ee4cbcbec15"},
{file = "lxml-4.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fa9b7c450be85bfc6cd39f6df8c5b8cbd76b5d6fc1f69efec80203f9894b885f"},
{file = "lxml-4.8.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e9f84ed9f4d50b74fbc77298ee5c870f67cb7e91dcdc1a6915cb1ff6a317476c"},
{file = "lxml-4.8.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1d650812b52d98679ed6c6b3b55cbb8fe5a5460a0aef29aeb08dc0b44577df85"},
{file = "lxml-4.8.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:80bbaddf2baab7e6de4bc47405e34948e694a9efe0861c61cdc23aa774fcb141"},
{file = "lxml-4.8.0-cp37-cp37m-win32.whl", hash = "sha256:6f7b82934c08e28a2d537d870293236b1000d94d0b4583825ab9649aef7ddf63"},
{file = "lxml-4.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e1fd7d2fe11f1cb63d3336d147c852f6d07de0d0020d704c6031b46a30b02ca8"},
{file = "lxml-4.8.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5045ee1ccd45a89c4daec1160217d363fcd23811e26734688007c26f28c9e9e7"},
{file = "lxml-4.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0c1978ff1fd81ed9dcbba4f91cf09faf1f8082c9d72eb122e92294716c605428"},
{file = "lxml-4.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cbf2ff155b19dc4d4100f7442f6a697938bf4493f8d3b0c51d45568d5666b5"},
{file = "lxml-4.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ce13d6291a5f47c1c8dbd375baa78551053bc6b5e5c0e9bb8e39c0a8359fd52f"},
{file = "lxml-4.8.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11527dc23d5ef44d76fef11213215c34f36af1608074561fcc561d983aeb870"},
{file = "lxml-4.8.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:60d2f60bd5a2a979df28ab309352cdcf8181bda0cca4529769a945f09aba06f9"},
{file = "lxml-4.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:62f93eac69ec0f4be98d1b96f4d6b964855b8255c345c17ff12c20b93f247b68"},
{file = "lxml-4.8.0-cp38-cp38-win32.whl", hash = "sha256:20b8a746a026017acf07da39fdb10aa80ad9877046c9182442bf80c84a1c4696"},
{file = "lxml-4.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:891dc8f522d7059ff0024cd3ae79fd224752676447f9c678f2a5c14b84d9a939"},
{file = "lxml-4.8.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:b6fc2e2fb6f532cf48b5fed57567ef286addcef38c28874458a41b7837a57807"},
{file = "lxml-4.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:74eb65ec61e3c7c019d7169387d1b6ffcfea1b9ec5894d116a9a903636e4a0b1"},
{file = "lxml-4.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:627e79894770783c129cc5e89b947e52aa26e8e0557c7e205368a809da4b7939"},
{file = "lxml-4.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:545bd39c9481f2e3f2727c78c169425efbfb3fbba6e7db4f46a80ebb249819ca"},
{file = "lxml-4.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5a58d0b12f5053e270510bf12f753a76aaf3d74c453c00942ed7d2c804ca845c"},
{file = "lxml-4.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec4b4e75fc68da9dc0ed73dcdb431c25c57775383fec325d23a770a64e7ebc87"},
{file = "lxml-4.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5804e04feb4e61babf3911c2a974a5b86f66ee227cc5006230b00ac6d285b3a9"},
{file = "lxml-4.8.0-cp39-cp39-win32.whl", hash = "sha256:aa0cf4922da7a3c905d000b35065df6184c0dc1d866dd3b86fd961905bbad2ea"},
{file = "lxml-4.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:dd10383f1d6b7edf247d0960a3db274c07e96cf3a3fc7c41c8448f93eac3fb1c"},
{file = "lxml-4.8.0-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:2403a6d6fb61c285969b71f4a3527873fe93fd0abe0832d858a17fe68c8fa507"},
{file = "lxml-4.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:986b7a96228c9b4942ec420eff37556c5777bfba6758edcb95421e4a614b57f9"},
{file = "lxml-4.8.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6fe4ef4402df0250b75ba876c3795510d782def5c1e63890bde02d622570d39e"},
{file = "lxml-4.8.0-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:f10ce66fcdeb3543df51d423ede7e238be98412232fca5daec3e54bcd16b8da0"},
{file = "lxml-4.8.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:730766072fd5dcb219dd2b95c4c49752a54f00157f322bc6d71f7d2a31fecd79"},
{file = "lxml-4.8.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8b99ec73073b37f9ebe8caf399001848fced9c08064effdbfc4da2b5a8d07b93"},
{file = "lxml-4.8.0.tar.gz", hash = "sha256:f63f62fc60e6228a4ca9abae28228f35e1bd3ce675013d1dfb828688d50c6e23"},
]
multidict = [
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
{file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
{file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
{file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
{file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
{file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
{file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
{file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
{file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
{file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
{file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
{file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
mysqlclient = [
{file = "mysqlclient-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:02c8826e6add9b20f4cb12dcf016485f7b1d6e30356a1204d05431867a1b3947"},
{file = "mysqlclient-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b62d23c11c516cedb887377c8807628c1c65d57593b57853186a6ee18b0c6a5b"},
{file = "mysqlclient-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:2c8410f54492a3d2488a6a53e2d85b7e016751a1e7d116e7aea9c763f59f5e8c"},
{file = "mysqlclient-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:e6279263d5a9feca3e0edbc2b2a52c057375bf301d47da2089c075ff76331d14"},
{file = "mysqlclient-2.1.0.tar.gz", hash = "sha256:973235686f1b720536d417bf0a0d39b4ab3d5086b2b6ad5e6752393428c02b12"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
peewee = [
{file = "peewee-3.14.10.tar.gz", hash = "sha256:23271422b332c82d30c92597dee905ee831b56c6d99c33e05901e6891c75fe15"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
]
pywin32 = [
{file = "pywin32-227-cp27-cp27m-win32.whl", hash = "sha256:371fcc39416d736401f0274dd64c2302728c9e034808e37381b5e1b22be4a6b0"},
{file = "pywin32-227-cp27-cp27m-win_amd64.whl", hash = "sha256:4cdad3e84191194ea6d0dd1b1b9bdda574ff563177d2adf2b4efec2a244fa116"},
{file = "pywin32-227-cp35-cp35m-win32.whl", hash = "sha256:f4c5be1a293bae0076d93c88f37ee8da68136744588bc5e2be2f299a34ceb7aa"},
{file = "pywin32-227-cp35-cp35m-win_amd64.whl", hash = "sha256:a929a4af626e530383a579431b70e512e736e9588106715215bf685a3ea508d4"},
{file = "pywin32-227-cp36-cp36m-win32.whl", hash = "sha256:300a2db938e98c3e7e2093e4491439e62287d0d493fe07cce110db070b54c0be"},
{file = "pywin32-227-cp36-cp36m-win_amd64.whl", hash = "sha256:9b31e009564fb95db160f154e2aa195ed66bcc4c058ed72850d047141b36f3a2"},
{file = "pywin32-227-cp37-cp37m-win32.whl", hash = "sha256:47a3c7551376a865dd8d095a98deba954a98f326c6fe3c72d8726ca6e6b15507"},
{file = "pywin32-227-cp37-cp37m-win_amd64.whl", hash = "sha256:31f88a89139cb2adc40f8f0e65ee56a8c585f629974f9e07622ba80199057511"},
{file = "pywin32-227-cp38-cp38-win32.whl", hash = "sha256:7f18199fbf29ca99dff10e1f09451582ae9e372a892ff03a28528a24d55875bc"},
{file = "pywin32-227-cp38-cp38-win_amd64.whl", hash = "sha256:7c1ae32c489dc012930787f06244426f8356e129184a02c25aef163917ce158e"},
{file = "pywin32-227-cp39-cp39-win32.whl", hash = "sha256:c054c52ba46e7eb6b7d7dfae4dbd987a1bb48ee86debe3f245a2884ece46e295"},
{file = "pywin32-227-cp39-cp39-win_amd64.whl", hash = "sha256:f27cec5e7f588c3d1051651830ecc00294f90728d19c3bf6916e6dba93ea357c"},
]
requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
]
"ruamel.yaml" = [
{file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"},
{file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"},
]
"ruamel.yaml.clib" = [
{file = "ruamel.yaml.clib-0.2.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6e7be2c5bcb297f5b82fee9c665eb2eb7001d1050deaba8471842979293a80b0"},
{file = "ruamel.yaml.clib-0.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:221eca6f35076c6ae472a531afa1c223b9c29377e62936f61bc8e6e8bdc5f9e7"},
{file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win32.whl", hash = "sha256:1070ba9dd7f9370d0513d649420c3b362ac2d687fe78c6e888f5b12bf8bc7bee"},
{file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:77df077d32921ad46f34816a9a16e6356d8100374579bc35e15bab5d4e9377de"},
{file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"},
{file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"},
{file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"},
{file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win_amd64.whl", hash = "sha256:de9c6b8a1ba52919ae919f3ae96abb72b994dd0350226e28f3686cb4f142165c"},
{file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d67f273097c368265a7b81e152e07fb90ed395df6e552b9fa858c6d2c9f42502"},
{file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:72a2b8b2ff0a627496aad76f37a652bcef400fd861721744201ef1b45199ab78"},
{file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win32.whl", hash = "sha256:9efef4aab5353387b07f6b22ace0867032b900d8e91674b5d8ea9150db5cae94"},
{file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win_amd64.whl", hash = "sha256:846fc8336443106fe23f9b6d6b8c14a53d38cef9a375149d61f99d78782ea468"},
{file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0847201b767447fc33b9c235780d3aa90357d20dd6108b92be544427bea197dd"},
{file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:78988ed190206672da0f5d50c61afef8f67daa718d614377dcd5e3ed85ab4a99"},
{file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win32.whl", hash = "sha256:a49e0161897901d1ac9c4a79984b8410f450565bbad64dbfcbf76152743a0cdb"},
{file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win_amd64.whl", hash = "sha256:bf75d28fa071645c529b5474a550a44686821decebdd00e21127ef1fd566eabe"},
{file = "ruamel.yaml.clib-0.2.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a32f8d81ea0c6173ab1b3da956869114cae53ba1e9f72374032e33ba3118c233"},
{file = "ruamel.yaml.clib-0.2.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7f7ecb53ae6848f959db6ae93bdff1740e651809780822270eab111500842a84"},
{file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win32.whl", hash = "sha256:89221ec6d6026f8ae859c09b9718799fea22c0e8da8b766b0b2c9a9ba2db326b"},
{file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win_amd64.whl", hash = "sha256:31ea73e564a7b5fbbe8188ab8b334393e06d997914a4e184975348f204790277"},
{file = "ruamel.yaml.clib-0.2.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc6a613d6c74eef5a14a214d433d06291526145431c3b964f5e16529b1842bed"},
{file = "ruamel.yaml.clib-0.2.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1866cf2c284a03b9524a5cc00daca56d80057c5ce3cdc86a52020f4c720856f0"},
{file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win32.whl", hash = "sha256:3fb9575a5acd13031c57a62cc7823e5d2ff8bc3835ba4d94b921b4e6ee664104"},
{file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win_amd64.whl", hash = "sha256:825d5fccef6da42f3c8eccd4281af399f21c02b32d98e113dbc631ea6a6ecbc7"},
{file = "ruamel.yaml.clib-0.2.6.tar.gz", hash = "sha256:4ff604ce439abb20794f05613c374759ce10e3595d1867764dd1ae675b85acbd"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
typed-ast = [
{file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
{file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
{file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
{file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
{file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
{file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
{file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
{file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
{file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
{file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
{file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
{file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
{file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
{file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
]
typing-extensions = [
{file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
{file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
]
udm-rest-client = [
{file = "udm-rest-client-1.0.7.tar.gz", hash = "sha256:b0468cd16a5ec3c8f1fb2da70c7db6437b2283fdfdb64cb0b21e36ad59a8ad98"},
{file = "udm_rest_client-1.0.7-py2.py3-none-any.whl", hash = "sha256:6ecf462fa5d48c11010e48c340270c95b99ad3ba4a571d02c3fcc0471339e35f"},
]
urllib3 = [
{file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"},
{file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
]
websocket-client = [
{file = "websocket-client-1.3.2.tar.gz", hash = "sha256:50b21db0058f7a953d67cc0445be4b948d7fc196ecbeb8083d68d94628e4abf6"},
{file = "websocket_client-1.3.2-py3-none-any.whl", hash = "sha256:722b171be00f2b90e1d4fb2f2b53146a536ca38db1da8ff49c972a4e1365d0ef"},
]
yarl = [
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"},
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"},
{file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"},
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"},
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"},
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"},
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"},
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"},
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"},
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"},
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"},
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"},
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"},
{file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"},
{file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"},
{file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"},
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"},
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"},
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"},
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"},
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"},
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"},
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"},
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"},
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"},
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"},
{file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"},
{file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"},
{file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"},
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"},
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"},
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"},
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"},
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"},
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"},
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"},
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"},
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"},
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"},
{file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"},
{file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"},
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"},
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"},
{file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"},
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"},
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"},
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"},
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"},
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"},
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"},
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"},
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"},
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"},
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"},
{file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"},
{file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"},
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"},
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"},
{file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"},
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"},
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"},
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"},
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"},
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"},
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"},
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"},
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"},
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"},
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"},
{file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"},
{file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"},
{file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"},
]
zipp = [
{file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"},
{file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"},
]

View File

@ -1,40 +0,0 @@
[tool.black]
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 88
[tool.poetry]
name = "memberPlumbing"
packages = [{ include = "memberPlumbing" }]
version = "0.1.0"
description = ""
authors = ["Adam Goldsmith <adam@adamgoldsmith.name>"]
[tool.poetry.dependencies]
python = "^3.7"
requests = "^2.23.0"
"ruamel.yaml" = "^0.17.20"
bitstring = "^3.1.6"
lxml = "^4.5.0"
peewee = "^3.13.2"
mysqlclient = "^2.1.0"
udm-rest-client = "^1.0.6"
[tool.poetry.dev-dependencies]
black = "^22.3.0"
isort = "^5.10.1"
[tool.poetry.scripts]
doorUpdater = 'memberPlumbing.doorUpdater:main'
hidEvents = 'memberPlumbing.hidEvents:main'
sqlExport = 'memberPlumbing.sqlExport:main'
ucsAccounts = 'memberPlumbing.ucsAccounts:main'
upcomingEvents = 'memberPlumbing.upcomingEvents:main'
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

65
schedules.xml Normal file
View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<VertXMessage xmlns:hid="http://www.hidglobal.com/VertX">
<hid:Schedules action="AD">
<hid:Schedule scheduleID="1" scheduleName="7x24">
<hid:DayOfWeekInterval dayOfWeek="0" startTime="00:00:00" endTime="23:59:59"/>
<hid:DayOfWeekInterval dayOfWeek="1" startTime="00:00:00" endTime="23:59:59"/>
<hid:DayOfWeekInterval dayOfWeek="2" startTime="00:00:00" endTime="23:59:59"/>
<hid:DayOfWeekInterval dayOfWeek="3" startTime="00:00:00" endTime="23:59:59"/>
<hid:DayOfWeekInterval dayOfWeek="4" startTime="00:00:00" endTime="23:59:59"/>
<hid:DayOfWeekInterval dayOfWeek="5" startTime="00:00:00" endTime="23:59:59"/>
<hid:DayOfWeekInterval dayOfWeek="6" startTime="00:00:00" endTime="23:59:59"/>
</hid:Schedule>
<hid:Schedule scheduleID="2" scheduleName="Weekends Only">
<hid:DayOfWeekInterval dayOfWeek="0" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="6" startTime="08:00:00" endTime="21:00:00"/>
<hid:SpecialDayInterval dayOfMonth="1" month="1" specialDayID="3" specialDayName="New Years Day" startTime="08:00:00" endTime="21:00:00"/>
<hid:SpecialDayInterval dayOfMonth="25" month="12" specialDayID="2" specialDayName="Christmas Day" startTime="08:00:00" endTime="21:00:00"/>
</hid:Schedule>
<hid:Schedule scheduleID="3" scheduleName="Weekdays Only">
<hid:DayOfWeekInterval dayOfWeek="1" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="2" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="3" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="4" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="5" startTime="08:00:00" endTime="21:00:00"/>
</hid:Schedule>
<hid:Schedule scheduleID="4" scheduleName="Unlimited">
<hid:DayOfWeekInterval dayOfWeek="0" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="1" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="2" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="3" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="4" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="5" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="6" startTime="08:00:00" endTime="21:00:00"/>
</hid:Schedule>
<hid:Schedule scheduleID="5" scheduleName="Nights and Weekends">
<hid:DayOfWeekInterval dayOfWeek="0" startTime="08:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="1" startTime="15:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="2" startTime="15:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="3" startTime="15:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="4" startTime="15:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="5" startTime="15:00:00" endTime="21:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="6" startTime="08:00:00" endTime="21:00:00"/>
</hid:Schedule>
<hid:Schedule scheduleID="6" scheduleName="Front Door Unlocker">
<hid:DayOfWeekInterval dayOfWeek="0" startTime="08:00:00" endTime="17:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="1" startTime="08:00:00" endTime="17:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="2" startTime="08:00:00" endTime="17:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="3" startTime="08:00:00" endTime="17:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="4" startTime="08:00:00" endTime="17:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="5" startTime="08:00:00" endTime="17:00:00"/>
<hid:DayOfWeekInterval dayOfWeek="6" startTime="08:00:00" endTime="17:00:00"/>
</hid:Schedule>
</hid:Schedules>
<hid:SpecialDays action="UD">
<hid:SpecialDay specialDayID="1" specialDayName="New Years Day" dayOfMonth="1" month="1"/>
<hid:SpecialDay specialDayID="2" specialDayName="Independance Day" dayOfMonth="4" month="7"/>
<hid:SpecialDay specialDayID="3" specialDayName="Christmas Day" dayOfMonth="25" month="12"/>
</hid:SpecialDays>
</VertXMessage>

146
sqlExport.py Executable file
View File

@ -0,0 +1,146 @@
#!/usr/bin/env python3
from datetime import datetime
import MySQLdb
import yaml
from common import membershipworks
from passwords import MEMBERSHIPWORKS_DB
def resolveSource(key, value):
if type(value) == str:
return value
elif type(value) == dict and 'source' in value:
return value['source']
else:
return key
def formatRows(tableMap, data):
for d in data:
yield [d.get(resolveSource(k, v)) for k, v in tableMap.items()]
def insertFromTableMap(table, data, tableMap):
# TODO: this could probably be done better as a single statement?
c.executemany(
f"""INSERT INTO {table} ({','.join(f'`{k}`' for k in tableMap.keys())})
VALUES ({','.join(len(tableMap) * ['%s'])})
ON DUPLICATE KEY UPDATE
{', '.join(f'`{k}`=VALUES(`{k}`)' for k in tableMap.keys())};""",
list(formatRows(tableMap, data)))
def insertDistinctFromTableMap(table, data, tableMap):
# TODO: this could probably be done better as a single statement?
c.executemany(
f"""INSERT INTO {table} ({','.join(f'`{k}`' for k in tableMap.keys())})
SELECT {','.join(len(tableMap) * ['%s'])} FROM DUAL
WHERE NOT EXISTS (
SELECT 1 from {table}
WHERE {' AND '.join(f'`{k}`<=>%s' for k in tableMap.keys())}
);""" ,
list([r * 2 for r in formatRows(tableMap, data)]))
# TODO: delete non-valid labels
def insertLabels(members):
for member in members:
for label, label_id in membershipworks._parse_flags()['labels'].items():
if member[label]:
c.execute("""
INSERT INTO member_labels (uid, label_id) VALUES (%s, %s)
ON DUPLICATE KEY UPDATE
uid=VALUES(uid), label_id=VALUES(label_id);""",
(member['Account ID'], label_id))
else:
c.execute('DELETE FROM member_labels WHERE uid=%s && label_id=%s;',
(member['Account ID'], label_id))
with open('tableMapping.yaml') as f:
tableMapping = yaml.load(f, yaml.SafeLoader)
conn = MySQLdb.connect(**MEMBERSHIPWORKS_DB)
conn.set_character_set('utf8')
c = conn.cursor()
def createDefinitionsFromTableMap(tableMap):
def resolveColType(value):
if type(value) == dict and 'type' in value:
return value['type']
else:
return 'TEXT'
return ', '.join([f'`{k}` ' + resolveColType(v)
for k, v in tableMap.items()])
try:
print("Creating tables...")
c.execute('CREATE TABLE IF NOT EXISTS members (' +
createDefinitionsFromTableMap(tableMapping['members']) +
') WITH SYSTEM VERSIONING;')
c.execute("CREATE TABLE IF NOT EXISTS transactions (" +
createDefinitionsFromTableMap(tableMapping['transactions']) +
", CONSTRAINT `fk_member_uid` FOREIGN KEY (uid) REFERENCES members(uid));")
#-- FOREIGN KEY event_id REFERENCES event eid
c.execute("""CREATE TABLE IF NOT EXISTS labels (
label_id CHAR(24) PRIMARY KEY, label TEXT
) WITH SYSTEM VERSIONING;""")
c.execute("""CREATE TABLE IF NOT EXISTS member_labels (
uid CHAR(24), label_id CHAR(24),
PRIMARY KEY(uid, label_id),
FOREIGN KEY(uid) REFERENCES members(uid),
FOREIGN KEY(label_id) REFERENCES labels(label_id)
) WITH SYSTEM VERSIONING;""")
print("Updating labels")
c.executemany("""INSERT INTO labels (label, label_id) VALUES (%s, %s)
ON DUPLICATE KEY UPDATE
label=VALUES(label), label_id=VALUES(label_id);""",
membershipworks._parse_flags()['labels'].items())
print("Getting/Updating members...")
members = membershipworks.get_all_members()
for m in members:
# replace flags by booleans
for flag in [dek['lbl'] for dek in membershipworks.org_info['dek']]:
if flag in m:
m[flag] = m[flag] == flag
for field_id, field in membershipworks._all_fields().items():
# convert checkboxes to real booleans
if field.get('typ') == 8 and field['lbl'] in m: # check box
m[field['lbl']] = True if m[field['lbl']] == 'Y' else False
insertFromTableMap('members', members, tableMapping['members'])
insertLabels(members)
print("Getting/Updating transactions...")
now = datetime.now()
transactions_csv = membershipworks.get_transactions(datetime(2010, 1, 1), now)
transactions_json = membershipworks.get_transactions(
datetime(2010, 1, 1), now, json=True)
# this is terrible, but as long as the dates are the same, should be fiiiine
transactions = [{**j, **v}
for j, v in zip(transactions_csv, transactions_json)]
assert all([t['Account ID'] == t.get('uid', '')
and t['Payment ID'] == t.get('sid', '')
for t in transactions])
insertDistinctFromTableMap(
'transactions', transactions, tableMapping['transactions'])
print("Committing changes...")
conn.commit()
except Exception as e:
print("Transaction failed, rolling back")
conn.rollback();
raise e
finally:
conn.close()
# TODO: folders, levels, addons

View File

@ -5,6 +5,5 @@ OnFailure=status-email-admin@%n.service
[Service]
User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run doorUpdater
WorkingDirectory=/home/adam/hidDoorWriter/
ExecStart=/home/adam/hidDoorWriter/doorUpdater.py

View File

@ -1,12 +0,0 @@
[Unit]
Description=Pull events from door controllers into local database
OnFailure=status-email-admin@%n.service
After=mariadb.service
Requires=mariadb.service
[Service]
User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run hidEvents

View File

@ -1,9 +0,0 @@
[Unit]
Description=Hourly door controller events sync
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,12 +0,0 @@
[Unit]
Description=Sync Membershipworks with local database
OnFailure=status-email-admin@%n.service
After=mariadb.service
Requires=mariadb.service
[Service]
User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run sqlExport

View File

@ -1,9 +0,0 @@
[Unit]
Description=Hourly Membershipworks database sync
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -1,10 +0,0 @@
[Unit]
Description=Update UCS Accounts
OnFailure=status-email-admin@%n.service
[Service]
User=adam
Type=oneshot
TimeoutStartSec=600
WorkingDirectory=/home/adam/memberPlumbing/
ExecStart=/usr/bin/python3 -u /home/adam/.poetry/bin/poetry run ucsAccounts

View File

@ -1,9 +0,0 @@
[Unit]
Description=Hourly UCS Accounts update
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target

102
tableMapping.yaml Normal file
View File

@ -0,0 +1,102 @@
members:
'uid': {type: 'CHAR(24) PRIMARY KEY', source: 'Account ID'}
'Year of Birth':
'Account Name':
'First Name':
'Last Name':
'Phone':
'Email':
'Address (Street)':
'Address (City)':
'Address (State/Province)':
'Address (Postal Code)':
'Address (Country)':
'Profile description':
'Website':
'Fax':
'Contact Person':
'Password':
'Position/relation':
'Parent Account ID':
'Gift Membership purchased by':
'Purchased Gift Membership for':
'Closet Storage #':
'Storage Shelf #':
'Personal Studio Space #':
'Access Permitted Shops During Extended Hours?': {type: 'BOOLEAN'}
'Access Front Door and Studio Space During Extended Hours?': {type: 'BOOLEAN'}
'Access Wood Shop?': {type: 'BOOLEAN'}
'Access Metal Shop?': {type: 'BOOLEAN'}
'Access Storage Closet?': {type: 'BOOLEAN'}
'Access Studio Space?': {type: 'BOOLEAN'}
'Access Front Door?': {type: 'BOOLEAN'}
'Access Card Number':
'Access Card Facility Code':
'Auto Billing ID':
'Billing Method':
'Renewal Date':
'Join Date':
'Admin note':
'Profile gallery image URL':
'Business card image URL':
'Instagram':
'Pinterest':
'Youtube':
'Yelp':
'Google+':
'BBB':
'Twitter':
'Facebook':
'LinkedIn':
'Do not show street address in profile':
'Do not list in directory':
'HowDidYouHear': 'Please tell us how you heard about the Claremont MakerSpace and what tools or shops you are most excited to start using:'
'authorizeCharge': 'Yes - I authorize TwinState MakerSpaces, Inc. to charge my credit card for the membership and other options that I have selected.'
'policyAgreement': 'I have read the Claremont MakerSpace Membership Agreement & Policies, and agree to all terms stated therein.'
'Waiver form signed and on file date.':
'Membership Agreement signed and on file date.':
'Agreement Version':
'Paperwork status':
'Membership agreement dated': {type: BOOLEAN}
'Membership Agreement Acknowledgement Page Filled Out': {type: BOOLEAN}
'Membership Agreement Signed': {type: BOOLEAN}
'Liability Form Filled Out': {type: BOOLEAN}
'Audit Date':
'IP Address':
transactions:
'sid': {type: CHAR(27)}
'uid': {type: CHAR(24)}
'timestamp': {type: 'INT(11)', source: '_dp'} # TODO: should be a real timestamp?
'type': {source: 'Transaction Type'}
'sum': {type: 'DECIMAL(13,4)'}
'fee': {type: 'DECIMAL(13,4)'}
'event_id': {source: 'eid'}
'For':
'Items':
'Discount Code':
'Note':
# this is painful, but necessary because some users have no uid
# TODO: fix this horribleness
'Name':
'Contact Person':
'Full Address':
'Street':
'City':
'State/Province':
'Postal Code':
'Country':
'Phone':
'Email':

54
templates/members.html Normal file
View File

@ -0,0 +1,54 @@
<html>
<head>
<title> CMS Members </title>
<style>
table {
border-collapse: collapse;
}
td, th{
border: 1px solid black;
}
td {
padding: 3px;
}
tr:nth-child(even) {
background-color: #ccc;
}
tr[onHold=Yes] {
background-color: #f66;
}
</style>
</head>
<body>
<form action="./frontDoor/lock" method="post">
<button>Lock Front Door</button>
<button formaction="./frontDoor/unlock">Unlock Front Door</button>
</form>
<form>
<input type="text" name="term" />
<button>Search</button>
</form>
{% if headers is not none %}
<table>
<tr>
{% for header in headers %}
<th> {{ header }} </th>
{% endfor %}
</tr>
{% for member in members %}
<tr onHold ="{{ member['Account on Hold'] }}">
{% for header in headers %}
<td> {{ member[header] }} </td>
{% endfor %}
</tr>
{% endfor %}
</div>
</table>
{% endif %}
<span class="error">{{ error }}</span>
</body>
</html>

89
ucsAccounts.py Executable file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
import random
import re
import string
import subprocess
from common import membershipworks
LDAP_BASE = "cn=users,dc=sawtooth,dc=claremontmakerspace,dc=org"
GROUP_BASE = "cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
GROUPS_REGEX = "|".join(['Certified: .*', 'Access .*\?', 'CMS .*', 'Volunteer: .*'])
RAND_PW_LEN = 20
def makeGroups(members):
groups = [key.replace(':', '.').replace('?', '')
for key in members[0].keys()
if re.match(GROUPS_REGEX, key) is not None]
for group in groups:
subprocess.call(["udm", "groups/group", "create",
"--position", GROUP_BASE,
"--set", "name=" + group])
def makeSets(props):
return sum([["--set", key + "=" + value]
for key, value in props.items()], [])
def makeAppendGroups(member):
groups = [key.replace(':', '.').replace('?', '')
for key, value in member.items()
if re.match(GROUPS_REGEX, key) is not None and value != '']
return sum([["--append", "groups=cn=" + group + ',' + GROUP_BASE]
for group in groups], [])
def main():
members = membershipworks.get_members(['Members', 'CMS Staff'],
"lvl,phn,eml,lbl,nam,end,_id")
makeGroups(members)
for member in members:
randomPass = ''.join(random.choice(string.ascii_letters + string.digits)
for x in range(0, RAND_PW_LEN))
username = member["Account Name"].lower().replace(" ", ".")
props = {
"title": "", # Title
"firstname": member["First Name"],
"lastname": member["Last Name"], # (c)
"username": username, # (cmr)
"description": "", # Description
"password": randomPass, # (c) Password
#"mailPrimaryAddress": member["Email"], # Primary e-mail address
#"displayName": "", # Display name
#"birthday": "", # Birthdate
#"jpegPhoto": "", # Picture of the user (JPEG format)
"employeeNumber": member["Account ID"],
#"employeeType": "", # Employee type
"homedrive": "H:", # Windows home drive
"sambahome": "\\\\ucs\\" + username, # Windows home path
"profilepath": "%LOGONSERVER%\\%USERNAME%\\windows-profiles\\default", # Windows profile directory
"disabled": "1" if member["Account on Hold"] != "" else "0",
#"userexpiry": member["Renewal Date"],
"pwdChangeNextLogin": "1", # User has to change password on next login
#"sambaLogonHours": "", # Permitted times for Windows logins
"e-mail": member["Email"], # ([]) E-mail address
"phone": member["Phone"], # Telephone number
#"PasswordRecoveryMobile": member["Phone"], # Mobile phone number
"PasswordRecoveryEmail": member["Email"]
}
subprocess.call(["udm", "users/user", "create",
"--position", LDAP_BASE] + makeSets(props))
# remove props we don't want to reset
props.pop("password")
props.pop("pwdChangeNextLogin")
subprocess.call(["udm", "users/user", "modify",
"--dn", "uid=" + username + "," + LDAP_BASE]
+ makeSets(props)
+ makeAppendGroups(member))
if __name__ == "__main__":
main()