from dataclasses import dataclass as _dataclass
from dataclasses import field as _field
from typing import List as _List
from typing import Dict as _Dict
__all__ = ["WardInfo", "WardInfos"]
[docs]@_dataclass
class WardInfo:
"""This class holds metadata about a ward, e.g. its name(s),
any ID code(s), any information about the region or
authority it is in etc.
"""
#: Name of the ward
name: str = ""
#: Any alternative names of the ward
alternate_names: _List[str] = _field(default_factory=list)
#: Official ID code of the ward
code: str = ""
#: Any alternative ID codes of the ward
alternate_codes: _List[str] = _field(default_factory=list)
#: The name of the local authority it is in
authority: str = ""
#: The ID of the local authority it is in
authority_code: str = ""
#: The name of the region it is in
region: str = ""
#: The ID of the region it is in
region_code: str = ""
[docs] def __hash__(self):
return f"{self.name} | {self.authority} | {self.region}".__hash__()
def is_null(self):
return self == WardInfo()
[docs] def summary(self):
"""Return a summary string that identifies this WardInfo"""
s = []
if len(self.name) > 0:
s.append(self.name)
elif len(self.alternate_names) > 0:
s.append(self.alternate_names[0])
elif len(self.code) > 0:
s.append(self.code)
elif len(self.alternate_codes) > 0:
s.append(self.alternate_codes[0])
if len(self.authority) > 0:
s.append(self.authority)
elif len(self.authority_code) > 0:
s.append(self.authority_code)
if len(self.region) > 0:
s.append(self.region)
elif len(self.region_code) > 0:
s.appened(self.region_code)
return "/".join(s)
[docs] def to_data(self):
"""Return a dictionary that contains all of this data, in
a format that can be serialised to JSON
"""
data = {}
if self.name is not None and len(self.name) > 0:
data["name"] = str(self.name)
if self.alternate_names is not None and len(self.alternate_names) > 0:
data["alternate_names"] = [str(x) for x in self.alternate_names]
if self.code is not None and len(self.code) > 0:
data["code"] = str(self.code)
if self.alternate_codes is not None and len(self.alternate_codes) > 0:
data["alternate_codes"] = [str(x) for x in self.alternate_codes]
if self.authority is not None and len(self.authority) > 0:
data["authority"] = str(self.authority)
if self.authority_code is not None and len(self.authority_code) > 0:
data["authority_code"] = str(self.authority_code)
if self.region is not None and len(self.region) > 0:
data["region"] = str(self.region)
if self.region_code is not None and len(self.region_code) > 0:
data["region_code"] = str(self.region_code)
return data
[docs] @staticmethod
def from_data(data):
"""Construct from the passed dictionary, which has, e.g. been
deserialised from JSON
"""
if data is None or len(data) == 0:
return WardInfo()
info = WardInfo()
info.name = str(data.get("name", ""))
info.alternate_names = [str(x) for x in
data.get("alternate_names", [])]
info.code = str(data.get("code", ""))
info.alternate_codes = [str(x) for x in
data.get("alternate_codes", [])]
info.authority = str(data.get("authority", ""))
info.authority_code = str(data.get("authority_code", ""))
info.region = str(data.get("region", ""))
info.region_code = str(data.get("region_code", ""))
return info
[docs]@_dataclass
class WardInfos:
"""Simple class that holds a list of WardInfo objects, and provides
useful search functions over that list. This prevents me from
cluttering up the interface of Network
"""
#: The list of WardInfo objects, one for each ward in order
wards: _List[WardInfo] = _field(default_factory=list)
#: The index used to speed up lookup of wards
_index: _Dict[WardInfo, int] = None
def __len__(self):
return len(self.wards)
def __getitem__(self, index: int) -> WardInfo:
return self.wards[index]
[docs] def __setitem__(self, i: int, info: WardInfo) -> None:
"""Set the ith WardInfo equal to 'info'."""
if info is not None:
if not isinstance(info, WardInfo):
raise TypeError(
f"Setting item at index {i} to not a WardInfo {info} "
f"is not allowed")
if i >= len(self.wards):
self.wards += [None] * (i - len(self.wards) + 1)
self.wards[i] = info
if info is not None and self._index is not None:
self._index[info] = i
return
elif i < 0:
i = len(self.wards) + i
if i < 0:
raise IndexError(f"Invalid index")
if self.wards[i] == info:
# nothing to do
return
elif self.wards[i] is not None:
self._index = None
self.wards[i] = info
return
else:
self.wards[i] = info
if self._index is not None:
index = self._index.get(info, None)
if index is None or index > i:
self._index[info] = i
return
[docs] def reindex(self):
"""Rebuild the WardInfo index. You must call this function after
you have modified the list of WardInfo objects, as otherwise
this will fall out of date. Note that this will be automatically
called the first time you use the "contains" or "index" functions
"""
self._index = {}
for i, ward in enumerate(self.wards):
if ward is not None:
if not isinstance(ward, WardInfo):
raise TypeError(
f"Item at index {i} is not a WardInfo! {ward}")
if ward not in self._index:
self._index[ward] = i
[docs] def __contains__(self, info: WardInfo) -> bool:
"""Return whether or not this contains the passed WardInfo"""
if self._index is None:
self.reindex()
return info in self._index
[docs] def contains(self, info: WardInfo) -> bool:
"""Return whether or not this contains the passed WardInfo"""
return self.__contains__(info)
[docs] def index(self, info: WardInfo) -> int:
"""Return the index of the passed 'info' object if it is in this
list. If not, then a ValueError exception is raised. Note that
only the first matching WardInfo will be returned
"""
if self._index is None:
self.reindex()
i = self._index.get(info, None)
if i is None:
raise ValueError(f"Missing ward! {info}")
else:
return i
def _find_ward(self, name: str, match: bool, include_alternates: bool):
"""Internal function that flexibly finds a ward by name"""
import re
if not isinstance(name, re.Pattern):
search = re.compile(name, re.IGNORECASE)
else:
search = name
if match:
search = search.match
else:
search = search.search
matches = []
for i, ward in enumerate(self.wards):
if ward is None:
continue
is_match = False
if search(ward.name):
is_match = True
elif search(ward.code):
is_match = True
elif include_alternates:
for alternate in ward.alternate_names:
if search(alternate):
is_match = True
break
if not is_match:
for alternate in ward.alternate_codes:
if search(alternate):
is_match = True
break
if is_match:
matches.append(i)
return matches
def _find_authority(self, name: str, match: bool):
"""Internal function that flexibly finds a ward by authority"""
import re
if not isinstance(name, re.Pattern):
search = re.compile(name, re.IGNORECASE)
else:
search = name
if match:
search = search.match
else:
search = search.search
matches = []
for i, ward in enumerate(self.wards):
if ward is None:
continue
is_match = False
if search(ward.authority):
is_match = True
elif search(ward.authority_code):
is_match = True
if is_match:
matches.append(i)
return matches
def _find_region(self, name: str, match: bool):
"""Internal function that flexibly finds a ward by region"""
import re
if not isinstance(name, re.Pattern):
search = re.compile(name, re.IGNORECASE)
else:
search = name
if match:
search = search.match
else:
search = search.search
matches = []
for i, ward in enumerate(self.wards):
if ward is None:
continue
is_match = False
if search(ward.region):
is_match = True
elif search(ward.region_code):
is_match = True
if is_match:
matches.append(i)
return matches
def _intersect(self, list1, list2):
"""Return the intersection of two lists"""
return [value for value in list1 if value in list2]
[docs] def find(self, name: str = None,
authority: str = None, region: str = None,
match: bool = False, match_authority_and_region: bool = False,
include_alternates: bool = True):
"""Generic search function that will search using any or all
of the terms provided. This returns a list of indicies
of wards that match the search
Parameters
----------
name: str or regexp
Name or code of the ward to search. You can also include
the authority adn region by separating usign "/", e.g.
"Clifton/Bristol".
authority: str or regexp
Name or code of the authority to search
region: str or regexp
Name or code of the region to search
match: bool(False)
Use a regular expression match for the ward rather than a
search. This forces the match to be at the start of the string
match_authority_and_region: bool(False)
Use a regular expression match for the authority and region
rather than a search. This forces the match to be at the start
of the string
include_alternates: bool(True)
Whether or not to include alternative names and codes when
searching for the ward
"""
wards = None
if name is not None:
parts = name.split("/")
if len(parts) == 1:
wards = self._find_ward(name, match=match,
include_alternates=include_alternates)
else:
wards = self._find_ward(name=parts[0].strip(), match=match,
include_alternates=include_alternates)
authority = parts[1].strip()
if len(parts) > 2:
region = "/".join(parts[2:]).strip()
if len(wards) == 0:
return wards
if authority is not None:
authorities = self._find_authority(
authority,
match=match_authority_and_region)
if len(authorities) == 0:
return authorities
if wards is None:
wards = authorities
else:
wards = self._intersect(wards, authorities)
wards.sort()
if len(wards) == 0:
return wards
if region is not None:
regions = self._find_region(region,
match=match_authority_and_region)
if len(regions) == 0:
return regions
if wards is None:
wards = regions
else:
wards = self._intersect(wards, regions)
wards.sort()
if wards is None:
# we have not searched for anything, so return everything
return list(range(1, len(self.wards)))
else:
return wards