Skip to content

Commit 1992402

Browse files
author
Christian Adell
authored
Merge pull request #76 from networktocode/release-v2.0.0
Release v2.0.0
2 parents 9bee2dd + ec42d37 commit 1992402

23 files changed

+956
-96
lines changed

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# Changelog
22

3-
## v2.0.0 -
3+
## v2.0.0 - 2021-09-15
44

55
### Added
66

7-
- #68 - Added new provider `HGC` using `Html` and `EmailSubjectParser`
7+
- #73 - Added new provider `Sparkle` using `Html` and `EmailSubjectParser`. Added support for multiple maintenances with `CombinedProcessor`.
8+
- #75 - Added new provider `AquaComms` using `Html` and `EmailSubjectParser`
9+
10+
### Fixed
11+
12+
- #72 - Ensure `NotificationData` init methods for library client do not raise exceptions and just return `None`.
813

914
## v2.0.0-beta - 2021-09-07
1015

@@ -23,6 +28,7 @@
2328
- #60 - Added new provider `Seaborn` using `Html` and a new parser for Email Subject: `EmailSubjectParser`
2429
- #61 - Added new provider `Colt` using `ICal` and `Csv`
2530
- #66 - Added new provider `Momentum` using `Html` and `EmailSubjectParser`
31+
- #68 - Added new provider `HGC` using `Html` and `EmailSubjectParser`
2632

2733
### Fixed
2834

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ By default, there is a `GenericProvider` that support a `SimpleProcessor` using
4343

4444
#### Supported providers based on other parsers
4545

46+
- AquaComms
4647
- Cogent
4748
- Colt
4849
- GTT
@@ -51,6 +52,7 @@ By default, there is a `GenericProvider` that support a `SimpleProcessor` using
5152
- Megaport
5253
- Momentum
5354
- Seaborn
55+
- Sparkle
5456
- Telstra
5557
- Turkcell
5658
- Verizon

circuit_maintenance_parser/__init__.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
"""Notifications parser init."""
2-
1+
"""Circuit-maintenance-parser init."""
32
from typing import Type, Optional
43

54
from .data import NotificationData
5+
from .output import Maintenance
66
from .errors import NonexistentProviderError, ProviderError
77
from .provider import (
88
GenericProvider,
9+
AquaComms,
910
Cogent,
1011
Colt,
1112
EUNetworks,
@@ -17,6 +18,7 @@
1718
NTT,
1819
PacketFabric,
1920
Seaborn,
21+
Sparkle,
2022
Telia,
2123
Telstra,
2224
Turkcell,
@@ -26,6 +28,7 @@
2628

2729
SUPPORTED_PROVIDERS = (
2830
GenericProvider,
31+
AquaComms,
2932
Cogent,
3033
Colt,
3134
EUNetworks,
@@ -37,6 +40,7 @@
3740
NTT,
3841
PacketFabric,
3942
Seaborn,
43+
Sparkle,
4044
Telia,
4145
Telstra,
4246
Turkcell,
@@ -60,21 +64,6 @@ def init_provider(provider_type=None) -> Optional[GenericProvider]:
6064
return None
6165

6266

63-
def init_data_raw(data_type: str, data_content: bytes) -> NotificationData:
64-
"""Returns an instance of NotificationData from one combination of data type and content."""
65-
return NotificationData.init(data_type, data_content)
66-
67-
68-
def init_data_email(raw_email_bytes: bytes) -> NotificationData:
69-
"""Returns an instance of NotificationData from a raw email content."""
70-
return NotificationData.init_from_email_bytes(raw_email_bytes)
71-
72-
73-
def init_data_emailmessage(email_message) -> NotificationData:
74-
"""Returns an instance of NotificationData from an email message."""
75-
return NotificationData.init_from_emailmessage(email_message)
76-
77-
7867
def get_provider_class(provider_name: str) -> Type[GenericProvider]:
7968
"""Returns the Provider parser class for a specific provider_type."""
8069
provider_name = provider_name.lower()
@@ -107,11 +96,10 @@ def get_provider_class_from_sender(email_sender: str) -> Type[GenericProvider]:
10796

10897
__all__ = [
10998
"init_provider",
110-
"init_data_raw",
111-
"init_data_email",
112-
"init_data_emailmessage",
99+
"NotificationData",
113100
"get_provider_class",
114101
"get_provider_class_from_sender",
115102
"ProviderError",
116103
"NonexistentProviderError",
104+
"Maintenance",
117105
]

circuit_maintenance_parser/cli.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import email
55
import click
66

7-
from . import SUPPORTED_PROVIDERS, init_provider, init_data_raw, init_data_emailmessage
7+
from . import SUPPORTED_PROVIDERS, init_provider
88
from .provider import ProviderError
9+
from .data import NotificationData
910

1011

1112
@click.command()
@@ -32,15 +33,15 @@ def main(provider_type, data_file, data_type, verbose):
3233
if str.lower(data_file[-3:]) == "eml":
3334
with open(data_file) as email_file:
3435
msg = email.message_from_file(email_file)
35-
data = init_data_emailmessage(msg)
36+
data = NotificationData.init_from_emailmessage(msg)
3637
else:
3738
click.echo("File format not supported, only *.eml", err=True)
3839
sys.exit(1)
3940

4041
else:
4142
with open(data_file, "rb") as raw_filename:
4243
raw_bytes = raw_filename.read()
43-
data = init_data_raw(data_type, raw_bytes)
44+
data = NotificationData.init_from_raw(data_type, raw_bytes)
4445

4546
try:
4647
parsed_notifications = provider.get_maintenances(data)

circuit_maintenance_parser/data.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Definition of Data classes."""
2-
from typing import List, NamedTuple
2+
import logging
3+
from typing import List, NamedTuple, Optional, Type, Set
34

45
import email
56
from pydantic import BaseModel, Extra
67

8+
logger = logging.getLogger(__name__)
9+
710

811
class DataPart(NamedTuple):
912
"""Simplest data unit to be parsed."""
@@ -23,16 +26,26 @@ def add_data_part(self, data_type: str, data_content: bytes):
2326
self.data_parts.append(DataPart(data_type, data_content))
2427

2528
@classmethod
26-
def init(cls, data_type: str, data_content: bytes):
29+
def init_from_raw(
30+
cls: Type["NotificationData"], data_type: str, data_content: bytes
31+
) -> Optional["NotificationData"]:
2732
"""Initialize the data_parts with only one DataPart object."""
28-
return cls(data_parts=[DataPart(data_type, data_content)])
33+
try:
34+
return cls(data_parts=[DataPart(data_type, data_content)])
35+
except Exception: # pylint: disable=broad-except
36+
logger.exception("Error found initializing data raw: %s, %s", data_type, data_content)
37+
return None
2938

3039
@classmethod
31-
def init_from_email_bytes(cls, raw_email_bytes: bytes):
40+
def init_from_email_bytes(cls: Type["NotificationData"], raw_email_bytes: bytes) -> Optional["NotificationData"]:
3241
"""Initialize the data_parts from an email defined as raw bytes.."""
33-
raw_email_string = raw_email_bytes.decode("utf-8")
34-
email_message = email.message_from_string(raw_email_string)
35-
return cls.init_from_emailmessage(email_message)
42+
try:
43+
raw_email_string = raw_email_bytes.decode("utf-8")
44+
email_message = email.message_from_string(raw_email_string)
45+
return cls.init_from_emailmessage(email_message)
46+
except Exception: # pylint: disable=broad-except
47+
logger.exception("Error found initializing data from email raw bytes: %s", raw_email_bytes)
48+
return None
3649

3750
@classmethod
3851
def walk_email(cls, email_message, data_parts):
@@ -53,13 +66,17 @@ def walk_email(cls, email_message, data_parts):
5366
data_parts.add(DataPart(part.get_content_type(), part.get_payload(decode=True)))
5467

5568
@classmethod
56-
def init_from_emailmessage(cls, email_message):
69+
def init_from_emailmessage(cls: Type["NotificationData"], email_message) -> Optional["NotificationData"]:
5770
"""Initialize the data_parts from an email.message.Email object."""
58-
data_parts = set()
59-
cls.walk_email(email_message, data_parts)
71+
try:
72+
data_parts: Set[DataPart] = set()
73+
cls.walk_email(email_message, data_parts)
6074

61-
# Adding extra headers that are interesting to be parsed
62-
data_parts.add(DataPart("email-header-subject", email_message["Subject"].encode()))
63-
# TODO: Date could be used to extend the "Stamp" time of a notification when not available, but we need a parser
64-
data_parts.add(DataPart("email-header-date", email_message["Date"].encode()))
65-
return cls(data_parts=list(data_parts))
75+
# Adding extra headers that are interesting to be parsed
76+
data_parts.add(DataPart("email-header-subject", email_message["Subject"].encode()))
77+
# TODO: Date could be used to extend the "Stamp" time of a notification when not available, but we need a parser
78+
data_parts.add(DataPart("email-header-date", email_message["Date"].encode()))
79+
return cls(data_parts=list(data_parts))
80+
except Exception: # pylint: disable=broad-except
81+
logger.exception("Error found initializing data from email message: %s", email_message)
82+
return None
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""AquaComms parser."""
2+
import logging
3+
import re
4+
from datetime import datetime
5+
6+
from circuit_maintenance_parser.parser import EmailSubjectParser, Html, Impact, CircuitImpact, Status
7+
8+
# pylint: disable=too-many-nested-blocks, too-many-branches
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class SubjectParserAquaComms1(EmailSubjectParser):
14+
"""Parser for Seaborn subject string, email type 1."""
15+
16+
def parse_subject(self, subject):
17+
"""Parse subject of email file.
18+
19+
Subject: Aqua Comms Planned Outage Work ISSUE=111111 PROJ=999
20+
"""
21+
data = {}
22+
search = re.search(r"ISSUE=([0-9]+).PROJ=([0-9]+)", subject)
23+
if search:
24+
data["maintenance_id"] = search.group(1)
25+
data["account"] = search.group(2)
26+
return [data]
27+
28+
29+
class HtmlParserAquaComms1(Html):
30+
"""Notifications Parser for AquaComms notifications."""
31+
32+
def parse_html(self, soup):
33+
"""Execute parsing."""
34+
data = {}
35+
self.parse_tables(soup.find_all("table"), data)
36+
return [data]
37+
38+
@staticmethod
39+
def get_tr_value(element):
40+
"""Remove new lines and split key to value."""
41+
return element.text.replace("\n", "").split(": ")[1].strip()
42+
43+
def parse_tables(self, tables, data):
44+
"""Parse HTML tables.
45+
46+
<table>
47+
<tbody>
48+
<tr>
49+
<td><font>Ticket Number:</font></td>
50+
<td><font>11111</font></td>
51+
</tr>
52+
<tr>
53+
<td><font>Scheduled Start Date & Time:</font></td>
54+
<td><font>22:00 12/10/2020 GMT</font></td>
55+
</tr>
56+
<tr>
57+
<td><font>Scheduled End Date & Time:</font></td>
58+
<td><font>22:00 12/10/2020 GMT</font></td>
59+
</tr>
60+
...
61+
</tbody>
62+
</table>
63+
"""
64+
for table in tables:
65+
for tr_element in table.find_all("tr"):
66+
if "ticket number" in tr_element.text.lower():
67+
data["maintenance_id"] = self.get_tr_value(tr_element)
68+
elif "update" in tr_element.text.lower():
69+
data["summary"] = tr_element.text.replace("\n", "").split(" - ")[1]
70+
elif "scheduled start date" in tr_element.text.lower():
71+
data["start"] = self.dt2ts(datetime.strptime(self.get_tr_value(tr_element), "%H:%M %d/%m/%Y %Z"))
72+
elif "scheduled end date" in tr_element.text.lower():
73+
data["end"] = self.dt2ts(datetime.strptime(self.get_tr_value(tr_element), "%H:%M %d/%m/%Y %Z"))
74+
elif "service id" in tr_element.text.lower():
75+
data["circuits"] = [
76+
CircuitImpact(circuit_id=self.get_tr_value(tr_element), impact=Impact("OUTAGE"))
77+
]
78+
data["status"] = Status.CONFIRMED
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Sparkle parser."""
2+
import logging
3+
from dateutil import parser
4+
5+
from circuit_maintenance_parser.errors import ParserError
6+
from circuit_maintenance_parser.parser import CircuitImpact, Html, Impact, Status
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class HtmlParserSparkle1(Html):
12+
"""Notifications HTML Parser 1 for Sparkle notifications.
13+
14+
Example:
15+
<table>
16+
<tbody>
17+
<tr>
18+
<td><p></p>Maintenance ID</td>
19+
<td><p></p>1111 / 2222</td>
20+
</tr>
21+
<tr>
22+
<td><p></p>Start Date/Time (UTC) Day 1</td>
23+
<td><p></p>08/10/2021 03:00 UTC</td>
24+
</tr>
25+
<tr>
26+
<td><p></p>End Date/Time (UTC) Day 1</td>
27+
<td><p></p>08/10/2021 11:00 UTC</td>
28+
</tr>
29+
<tr>
30+
<td><p></p>Start Date/Time (UTC) Day 2</td>
31+
<td><p></p>08/11/2021 03:00 UTC</td>
32+
</tr>
33+
<tr>
34+
<td><p></p>End Date/Time (UTC) Day 2</td>
35+
<td><p></p>08/11/2021 11:00 UTC</td>
36+
</tr>
37+
...
38+
</tbody>
39+
</table>
40+
"""
41+
42+
def parse_html(self, soup):
43+
"""Execute parsing."""
44+
data = {}
45+
try:
46+
return self.parse_tables(soup.find_all("table"), data)
47+
except Exception as exc:
48+
raise ParserError from exc
49+
50+
def clean_string(self, string):
51+
"""Remove hex characters and new lines."""
52+
return self.remove_hex_characters(string.replace("\n", "")).strip()
53+
54+
@staticmethod
55+
def set_all_tickets(tickets, attribute, value):
56+
"""Set the same value for all notifications."""
57+
for ticket in tickets:
58+
ticket[attribute] = value
59+
60+
def parse_tables(self, tables, data_base):
61+
"""Parse HTML tables."""
62+
data = []
63+
for table in tables:
64+
tr_elements = table.find_all("tr")
65+
for idx, tr_element in enumerate(tr_elements):
66+
td_elements = tr_element.find_all("td")
67+
if "sparkle ticket number" in td_elements[0].text.lower():
68+
tickets = self.clean_string(td_elements[1].text).split("/ ")
69+
for ticket_id in tickets:
70+
ticket = data_base.copy()
71+
ticket["maintenance_id"] = ticket_id
72+
if "start date/time" in tr_elements[idx + 1].text.lower():
73+
start = self.clean_string(tr_elements[idx + 1].find_all("td")[1].text)
74+
ticket["start"] = self.dt2ts(parser.parse(start))
75+
else:
76+
raise ParserError("Unable to find start time for ticket " + ticket_id)
77+
if "end date/time" in tr_elements[idx + 2].text.lower():
78+
end = self.clean_string(tr_elements[idx + 2].find_all("td")[1].text)
79+
ticket["end"] = self.dt2ts(parser.parse(end))
80+
else:
81+
raise ParserError("Unable to find end time for ticket " + ticket_id)
82+
idx += 2
83+
data.append(ticket)
84+
elif "circuits involved" in td_elements[0].text.lower():
85+
self.set_all_tickets(
86+
data,
87+
"circuits",
88+
[CircuitImpact(impact=Impact.OUTAGE, circuit_id=self.clean_line(td_elements[1].text))],
89+
)
90+
elif "description of work" in td_elements[0].text.lower():
91+
self.set_all_tickets(data, "summary", self.clean_string(td_elements[1].text))
92+
self.set_all_tickets(data, "status", Status.CONFIRMED)
93+
self.set_all_tickets(data, "account", "Not Available")
94+
return data

0 commit comments

Comments
 (0)