Skip to content

Commit 4f4a64f

Browse files
authored
Merge pull request #299 from networktocode/release-v2.7.0
Release v2.7.0
2 parents 30acf33 + 2078966 commit 4f4a64f

File tree

70 files changed

+3557
-841
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+3557
-841
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ jobs:
113113
strategy:
114114
fail-fast: true
115115
matrix:
116-
python-version: ["3.8", "3.9", "3.10", "3.11"]
116+
python-version: ["3.9", "3.10", "3.11", "3.12"]
117117
pydantic: ["2.x"]
118118
include:
119119
- python-version: "3.11"

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
# Changelog
4+
5+
## v2.7.0 - 2025-01-10
6+
7+
### Added
8+
9+
- [#303](https://github.com/networktocode/circuit-maintenance-parser/pull/303) - Add new parser for Apple
10+
- [#302](https://github.com/networktocode/circuit-maintenance-parser/pull/302) - Add support for Python 3.12
11+
- [#301](https://github.com/networktocode/circuit-maintenance-parser/pull/301) - Add new parser for PCCW
12+
- [#297](https://github.com/networktocode/circuit-maintenance-parser/pull/297) - Add new parser for Tata Communications
13+
14+
### Changed
15+
16+
- [#302](https://github.com/networktocode/circuit-maintenance-parser/pull/302) - Drop support for Python 3.8
17+
- [#291](https://github.com/networktocode/circuit-maintenance-parser/pull/291) - Update Windstream Parser for new emails
18+
19+
### Dependencies
20+
21+
- [#295](https://github.com/networktocode/circuit-maintenance-parser/pull/295) - Remove pydantic dotenv extra
22+
323
## v2.6.1 - 2024-06-04
424

525
### Fixed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ By default, there is a `GenericProvider` that supports a `SimpleProcessor` using
6363
- EXA (formerly GTT) (\*)
6464
- NTT
6565
- PacketFabric
66+
- PCCW
6667
- Telstra (\*)
6768

6869
#### Supported providers based on other parsers
6970

71+
- Apple
7072
- AWS
7173
- AquaComms
7274
- BSO
@@ -82,8 +84,10 @@ By default, there is a `GenericProvider` that supports a `SimpleProcessor` using
8284
- Megaport
8385
- Momentum
8486
- Netflix (AS2906 only)
87+
- PCCW
8588
- Seaborn
8689
- Sparkle
90+
- Tata
8791
- Telstra (\*)
8892
- Turkcell
8993
- Verizon

circuit_maintenance_parser/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
HGC,
1313
NTT,
1414
AquaComms,
15+
Apple,
1516
Arelion,
1617
Cogent,
1718
Colt,
@@ -26,8 +27,10 @@
2627
Momentum,
2728
Netflix,
2829
PacketFabric,
30+
PCCW,
2931
Seaborn,
3032
Sparkle,
33+
Tata,
3134
Telia,
3235
Telstra,
3336
Turkcell,
@@ -38,6 +41,7 @@
3841

3942
SUPPORTED_PROVIDERS = (
4043
GenericProvider,
44+
Apple,
4145
AquaComms,
4246
Arelion,
4347
AWS,
@@ -57,8 +61,10 @@
5761
Netflix,
5862
NTT,
5963
PacketFabric,
64+
PCCW,
6065
Seaborn,
6166
Sparkle,
67+
Tata,
6268
Telia,
6369
Telstra,
6470
Turkcell,

circuit_maintenance_parser/parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Parser(BaseModel):
4343
def get_data_types(cls) -> List[str]:
4444
"""Return the expected data type."""
4545
try:
46-
return cls._data_types.get_default()
46+
return cls._data_types.get_default() # type: ignore[attr-defined]
4747
except AttributeError:
4848
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
4949
return cls()._data_types
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Apple peering parser."""
2+
import email
3+
import re
4+
5+
from datetime import datetime, timezone
6+
from typing import Dict, List
7+
8+
from circuit_maintenance_parser.output import Impact, Status
9+
from circuit_maintenance_parser.parser import EmailSubjectParser, Text, CircuitImpact
10+
11+
12+
class SubjectParserApple(EmailSubjectParser):
13+
"""Subject parser for Apple notification."""
14+
15+
def parse_subject(self, subject: str) -> List[Dict]:
16+
"""Use the subject of the email as summary.
17+
18+
Args:
19+
subject (str): Message subjects
20+
21+
Returns:
22+
List[Dict]: List of attributes for Maintenance object
23+
"""
24+
return [{"summary": subject}]
25+
26+
27+
class TextParserApple(Text):
28+
"""Parse the plaintext content of an Apple notification.
29+
30+
Args:
31+
Text (str): Plaintext message
32+
"""
33+
34+
def parse_text(self, text: str) -> List[Dict]:
35+
"""Extract attributes from an Apple notification email.
36+
37+
Args:
38+
text (str): plaintext message
39+
40+
Returns:
41+
List[Dict]: List of attributes for Maintenance object
42+
"""
43+
data = {
44+
"circuits": self._circuits(text),
45+
"maintenance_id": self._maintenance_id(text),
46+
"start": self._start_time(text),
47+
"stamp": self._start_time(text),
48+
"end": self._end_time(text),
49+
"status": Status.CONFIRMED, # Have yet to see anything but confirmation.
50+
"organizer": "peering-noc@group.apple.com",
51+
"provider": "apple",
52+
"account": "Customer info unavailable",
53+
}
54+
return [data]
55+
56+
def _circuits(self, text):
57+
pattern = r"Peer AS: (\d*)"
58+
match = re.search(pattern, text)
59+
return [CircuitImpact(circuit_id=f"AS{match.group(1)}", impact=Impact.OUTAGE)]
60+
61+
def _maintenance_id(self, text):
62+
# Apple ticket numbers always starts with "CHG".
63+
pattern = r"CHG(\d*)"
64+
match = re.search(pattern, text)
65+
return match.group(0)
66+
67+
def _get_time(self, pattern, text):
68+
# Apple sends timestamps as RFC2822 for the US
69+
# but a custom format for EU datacenters.
70+
match = re.search(pattern, text)
71+
try:
72+
# Try EU timestamp
73+
return int(
74+
datetime.strptime(match.group(1), "%Y-%m-%d(%a) %H:%M %Z").replace(tzinfo=timezone.utc).timestamp()
75+
)
76+
except ValueError:
77+
# Try RFC2822 - US timestamp
78+
rfc2822 = match.group(1)
79+
time_tuple = email.utils.parsedate_tz(rfc2822)
80+
return email.utils.mktime_tz(time_tuple)
81+
82+
def _start_time(self, text):
83+
pattern = "Start Time: ([a-zA-Z0-9 :()-]*)"
84+
return self._get_time(pattern, text)
85+
86+
def _end_time(self, text):
87+
pattern = "End Time: ([a-zA-Z0-9 :()-]*)"
88+
return self._get_time(pattern, text)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Circuit maintenance parser for PCCW Email notifications."""
2+
import re
3+
from typing import List, Dict, Tuple, Any, ClassVar
4+
from datetime import datetime
5+
6+
from bs4.element import ResultSet # type: ignore
7+
from circuit_maintenance_parser.output import Status
8+
from circuit_maintenance_parser.parser import Html, EmailSubjectParser
9+
10+
11+
class HtmlParserPCCW(Html):
12+
"""Custom Parser for HTML portion of PCCW circuit maintenance notifications."""
13+
14+
DATE_TIME_FORMAT: ClassVar[str] = "%d/%m/%Y %H:%M:%S"
15+
PROVIDER: ClassVar[str] = "PCCW Global"
16+
17+
def parse_html(self, soup: ResultSet) -> List[Dict]:
18+
"""Parse PCCW circuit maintenance email.
19+
20+
Args:
21+
soup: BeautifulSoup ResultSet containing the email HTML content
22+
23+
Returns:
24+
List containing a dictionary with parsed maintenance data
25+
"""
26+
data: Dict[str, Any] = {
27+
"circuits": [],
28+
"provider": self.PROVIDER,
29+
"account": self._extract_account(soup),
30+
}
31+
start_time, end_time = self._extract_maintenance_window(soup)
32+
data["start"] = self.dt2ts(start_time)
33+
data["end"] = self.dt2ts(end_time)
34+
35+
return [data]
36+
37+
def _extract_account(self, soup: ResultSet) -> str:
38+
"""Extract customer account from soup."""
39+
customer_field = soup.find(string=re.compile("Customer Name :", re.IGNORECASE))
40+
return customer_field.split(":")[1].strip()
41+
42+
def _extract_maintenance_window(self, soup: ResultSet) -> Tuple[datetime, datetime]:
43+
"""Extract start and end times from maintenance window."""
44+
datetime_field = soup.find(string=re.compile("Date Time :", re.IGNORECASE))
45+
time_parts = (
46+
datetime_field.lower().replace("date time :", "-").replace("to", "-").replace("gmt", "-").split("-")
47+
)
48+
start_time = datetime.strptime(time_parts[1].strip(), self.DATE_TIME_FORMAT)
49+
end_time = datetime.strptime(time_parts[2].strip(), self.DATE_TIME_FORMAT)
50+
return start_time, end_time
51+
52+
53+
class SubjectParserPCCW(EmailSubjectParser):
54+
"""Custom Parser for Email subject of PCCW circuit maintenance notifications.
55+
56+
This parser extracts maintenance ID, status and summary from the email subject line.
57+
"""
58+
59+
# Only completion notification doesn't come with ICal. Other such as planned outage, urgent maintenance,
60+
# amendment and cacellation notifications come with ICal. Hence, maintenance status is set to COMPLETED.
61+
DEFAULT_STATUS: ClassVar[Status] = Status.COMPLETED
62+
63+
def parse_subject(self, subject: str) -> List[Dict]:
64+
"""Parse PCCW circuit maintenance email subject.
65+
66+
Args:
67+
subject: Email subject string to parse
68+
69+
Returns:
70+
List containing a dictionary with parsed subject data including:
71+
- maintenance_id: Extracted from end of subject
72+
- status: Default COMPLETED status
73+
- summary: Cleaned subject line
74+
"""
75+
data: Dict[str, Any] = {
76+
"maintenance_id": self._extract_maintenance_id(subject),
77+
"status": self.DEFAULT_STATUS,
78+
"summary": self._clean_summary(subject),
79+
}
80+
81+
return [data]
82+
83+
def _extract_maintenance_id(self, subject: str) -> str:
84+
"""Extract maintenance ID from the end of subject line."""
85+
return subject.split("-")[-1].strip()
86+
87+
def _clean_summary(self, subject: str) -> str:
88+
"""Clean and format the summary text."""
89+
return subject.strip().replace("\n", "")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# pylint: disable=disallowed-name
2+
"""Circuit maintenance parser for Tata Email notifications."""
3+
from typing import List, Dict, Any
4+
from datetime import datetime
5+
6+
from bs4.element import ResultSet # type: ignore
7+
from circuit_maintenance_parser.output import Impact, Status
8+
from circuit_maintenance_parser.parser import Html, EmailSubjectParser
9+
10+
11+
class HtmlParserTata(Html):
12+
"""Custom Parser for HTML portion of Tata circuit maintenance notifications."""
13+
14+
def parse_html(self, soup: ResultSet) -> List[Dict]:
15+
"""Parse Tata circuit maintenance email."""
16+
prev: str = ""
17+
data: Dict[str, Any] = {
18+
"account": "N/A",
19+
"circuits": [],
20+
"organizer": soup.select("a[href^=mailto]")[0].text.strip(),
21+
}
22+
for span in soup.find_all("span"):
23+
curr = span.text.strip()
24+
if curr != prev:
25+
prev_lower = prev.lower()
26+
if prev_lower == "ticket reference - tcl":
27+
data["maintenance_id"] = curr
28+
elif prev_lower == "service id":
29+
for circuit in curr.split(","):
30+
data["circuits"].append(
31+
{
32+
"circuit_id": circuit.strip(),
33+
"impact": Impact.OUTAGE,
34+
}
35+
)
36+
elif prev_lower in ("activity window (gmt)", "revised activity window (gmt)"):
37+
start_end = curr.split("to")
38+
data["start"] = self.dt2ts(datetime.strptime(start_end[0].strip(), "%Y-%m-%d %H:%M:%S %Z"))
39+
data["end"] = self.dt2ts(datetime.strptime(start_end[1].strip(), "%Y-%m-%d %H:%M:%S %Z"))
40+
elif "extended up to time window" in prev_lower:
41+
if "gmt" in curr.lower():
42+
data["end"] = self.dt2ts(datetime.strptime(curr, "%Y-%m-%d %H:%M:%S %Z"))
43+
prev = span.text.strip()
44+
45+
return [data]
46+
47+
48+
class SubjectParserTata(EmailSubjectParser):
49+
"""Custom Parser for Email subject of Tata circuit maintenance notifications."""
50+
51+
def parse_subject(self, subject: str) -> List[Dict]:
52+
"""Parse Tata Email subject for summary and status."""
53+
data: Dict[str, Any] = {"summary": subject.strip().replace("\n", "")}
54+
subject_lower = subject.lower()
55+
if "completion" in subject_lower:
56+
data["status"] = Status.COMPLETED
57+
elif "reschedule" in subject_lower or "extension" in subject_lower:
58+
data["status"] = Status.RE_SCHEDULED
59+
elif "reminder" in subject_lower:
60+
data["status"] = Status.CONFIRMED
61+
elif "cancellation" in subject_lower:
62+
data["status"] = Status.CANCELLED
63+
else:
64+
data["status"] = Status.CONFIRMED
65+
66+
return [data]

circuit_maintenance_parser/parsers/windstream.py

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,28 +41,25 @@ def parse_html(self, soup):
4141

4242
data["summary"] = summary_text
4343

44-
table = soup.find("table")
45-
for row in table.find_all("tr"):
46-
if len(row) < 2:
47-
continue
48-
cols = row.find_all("td")
49-
header_tag = cols[0].string
50-
if header_tag is None or header_tag == "Maintenance Address:":
51-
continue
52-
header_tag = header_tag.string.strip()
53-
value_tag = cols[1].string.strip()
54-
if header_tag == "WMT:":
55-
data["maintenance_id"] = value_tag
56-
elif "Date & Time:" in header_tag:
57-
dt_time = convert_timezone(value_tag)
58-
if "Event Start" in header_tag:
59-
data["start"] = int(dt_time.replace(tzinfo=timezone.utc).timestamp())
60-
elif "Event End" in header_tag:
61-
data["end"] = int(dt_time.replace(tzinfo=timezone.utc).timestamp())
62-
elif header_tag == "Outage":
63-
impact = Impact("OUTAGE")
64-
else:
65-
continue
44+
impact = soup.find("td", string="Outage").find_next_sibling("td").string
45+
if impact:
46+
impact = Impact("OUTAGE")
47+
48+
maint_id = soup.find("td", string="WMT:").find_next_sibling("td").string
49+
if maint_id:
50+
data["maintenance_id"] = maint_id
51+
52+
event = soup.find("td", string="Event Start Date & Time:").find_next_sibling("td").string
53+
if event:
54+
dt_time = convert_timezone(event)
55+
data["start"] = int(dt_time.replace(tzinfo=timezone.utc).timestamp())
56+
event = ""
57+
58+
event = soup.find("td", string="Event End Date & Time:").find_next_sibling("td").string
59+
if event:
60+
dt_time = convert_timezone(event)
61+
data["end"] = int(dt_time.replace(tzinfo=timezone.utc).timestamp())
62+
event = ""
6663

6764
table = soup.find("table", "circuitTable")
6865
for row in table.find_all("tr"):

0 commit comments

Comments
 (0)