Skip to content

Commit 10acd84

Browse files
authored
Merge pull request #282 from networktocode/release-v2.6.0
Release v2.6.0
2 parents e4c3a0d + 76b2898 commit 10acd84

23 files changed

+1013
-259
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ jobs:
114114
fail-fast: true
115115
matrix:
116116
python-version: ["3.8", "3.9", "3.10", "3.11"]
117+
pydantic: ["2.x"]
118+
include:
119+
- python-version: "3.11"
120+
pydantic: "1.x"
117121
runs-on: "ubuntu-20.04"
118122
env:
119123
INVOKE_LOCAL: "True"
@@ -128,6 +132,9 @@ jobs:
128132
poetry-install-options: "--with dev"
129133
- name: "Run poetry Install"
130134
run: "poetry install"
135+
- name: "Run poetry Install"
136+
run: "pip install pydantic==1.10.13"
137+
if: matrix.pydantic == '1.x'
131138
- name: "Run Tests"
132139
run: "poetry run invoke pytest --local"
133140
needs:

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
# Changelog
22

3-
## v.2.5.0 - 2024-03-13
3+
## v2.6.0 - 2024-04-04
4+
5+
### Added
6+
7+
- [#273](https://github.com/networktocode/circuit-maintenance-parser/pull/273) - Add iCal parsing to GTT/EXA
8+
- [#280](https://github.com/networktocode/circuit-maintenance-parser/pull/280) - Add new Windstream Parser
9+
10+
### Changed
11+
12+
- [#277](https://github.com/networktocode/circuit-maintenance-parser/pull/277) - Refactor the output validator `validate_empty_circuit`
13+
- [#281](https://github.com/networktocode/circuit-maintenance-parser/pull/281) Add the ability to support pydantic 1 and 2
14+
15+
### Fixed
16+
17+
- [#272](https://github.com/networktocode/circuit-maintenance-parser/pull/272) - Fix the logic in the output validator `validate_empty_circuit`
18+
- [#278](https://github.com/networktocode/circuit-maintenance-parser/pull/278) - Increase robustness of Crown Castle parsing
19+
20+
## v2.5.0 - 2024-03-13
421

522
### Added
623

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ By default, there is a `GenericProvider` that supports a `SimpleProcessor` using
6060

6161
- Arelion (previously Telia)
6262
- EuNetworks
63+
- EXA (formerly GTT) (\*)
6364
- NTT
6465
- PacketFabric
65-
- Telstra
66+
- Telstra (\*)
6667

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

@@ -73,7 +74,7 @@ By default, there is a `GenericProvider` that supports a `SimpleProcessor` using
7374
- Colt
7475
- Crown Castle Fiber
7576
- Equinix
76-
- EXA (formerly GTT)
77+
- EXA (formerly GTT) (\*)
7778
- HGC
7879
- Global Cloud Xchange
7980
- Google
@@ -83,11 +84,14 @@ By default, there is a `GenericProvider` that supports a `SimpleProcessor` using
8384
- Netflix (AS2906 only)
8485
- Seaborn
8586
- Sparkle
86-
- Telstra
87+
- Telstra (\*)
8788
- Turkcell
8889
- Verizon
90+
- Windstream
8991
- Zayo
9092

93+
(\*) Providers in both lists, with BCOP standard and nonstandard parsers.
94+
9195
> Note: Because these providers do not support the BCOP standard natively, maybe there are some gaps on the implemented parser that will be refined with new test cases. We encourage you to report related **issues**!
9296
9397
#### LLM-powered Parsers

circuit_maintenance_parser/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Telstra,
3333
Turkcell,
3434
Verizon,
35+
Windstream,
3536
Zayo,
3637
)
3738

@@ -62,6 +63,7 @@
6263
Telstra,
6364
Turkcell,
6465
Verizon,
66+
Windstream,
6567
Zayo,
6668
)
6769

circuit_maintenance_parser/output.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

99
from typing import List
1010

11-
from pydantic import field_validator, BaseModel, StrictStr, StrictInt, PrivateAttr
11+
try:
12+
from pydantic import field_validator
13+
except ImportError:
14+
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
15+
from pydantic import validator as field_validator # type: ignore
16+
17+
18+
from pydantic import BaseModel, StrictStr, StrictInt, PrivateAttr
1219

1320

1421
class Impact(str, Enum):
@@ -197,7 +204,7 @@ def validate_empty_strings(cls, value):
197204
def validate_empty_circuits(cls, value, values):
198205
"""Validate non-cancel notifications have a populated circuit list."""
199206
values = values.data
200-
if len(value) < 1 and str(values["status"]) in ("CANCELLED", "COMPLETED"):
207+
if len(value) < 1 and values["status"] not in (Status.CANCELLED, Status.COMPLETED):
201208
raise ValueError("At least one circuit has to be included in the maintenance")
202209
return value
203210

circuit_maintenance_parser/parser.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ class Parser(BaseModel):
4242
@classmethod
4343
def get_data_types(cls) -> List[str]:
4444
"""Return the expected data type."""
45-
return cls._data_types.get_default()
45+
try:
46+
return cls._data_types.get_default()
47+
except AttributeError:
48+
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
49+
return cls._data_types
4650

4751
@classmethod
4852
def get_name(cls) -> str:

circuit_maintenance_parser/parsers/crowncastle.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,8 @@ def parse_strong(self, soup, data):
5656
for strong in soup.find_all("strong"):
5757
if strong.string.strip() == "Ticket Number:":
5858
data["maintenance_id"] = strong.next_sibling.strip()
59-
if strong.string.strip() == "Description:":
60-
summary = strong.parent.next_sibling.next_sibling.contents[0].string.strip()
61-
summary = re.sub(r"[\n\r]", "", summary)
62-
data["summary"] = summary
63-
if strong.string.strip().startswith("Work Description:"):
59+
val = strong.string.strip()
60+
if val == "Description:" or val.startswith("Work Description:"):
6461
for sibling in strong.parent.next_siblings:
6562
summary = "".join(sibling.strings)
6663
summary = re.sub(r"[\n\r]", "", summary)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Windstream parser."""
2+
import logging
3+
from datetime import timezone
4+
5+
from circuit_maintenance_parser.parser import Html, Impact, CircuitImpact, Status
6+
from circuit_maintenance_parser.utils import convert_timezone
7+
8+
# pylint: disable=too-many-nested-blocks, too-many-branches
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class HtmlParserWindstream1(Html):
14+
"""Notifications Parser for Windstream notifications."""
15+
16+
def parse_html(self, soup):
17+
"""Execute parsing."""
18+
data = {}
19+
data["circuits"] = []
20+
impact = Impact("NO-IMPACT")
21+
confirmation_words = [
22+
"Demand Maintenance Notification",
23+
"Planned Maintenance Notification",
24+
"Emergency Maintenance Notification",
25+
]
26+
cancellation_words = ["Postponed Maintenance Notification", "Cancelled Maintenance Notification"]
27+
28+
h1_tag = soup.find("h1")
29+
if h1_tag.string.strip() == "Completed Maintenance Notification":
30+
data["status"] = Status("COMPLETED")
31+
elif any(keyword in h1_tag.string.strip() for keyword in confirmation_words):
32+
data["status"] = Status("CONFIRMED")
33+
elif h1_tag.string.strip() == "Updated Maintenance Notification":
34+
data["status"] = Status("RE-SCHEDULED")
35+
elif any(keyword in h1_tag.string.strip() for keyword in cancellation_words):
36+
data["status"] = Status("CANCELLED")
37+
38+
div_tag = h1_tag.find_next_sibling("div")
39+
summary_text = div_tag.get_text(separator="\n", strip=True)
40+
summary_text = summary_text.split("\nDESCRIPTION OF MAINTENANCE")[0]
41+
42+
data["summary"] = summary_text
43+
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
66+
67+
table = soup.find("table", "circuitTable")
68+
for row in table.find_all("tr"):
69+
cols = row.find_all("td")
70+
if len(cols) == 9:
71+
if cols[0].string.strip() == "Name":
72+
continue
73+
data["account"] = cols[0].string.strip()
74+
data["circuits"].append(CircuitImpact(impact=impact, circuit_id=cols[2].string.strip()))
75+
76+
return [data]

circuit_maintenance_parser/provider.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from circuit_maintenance_parser.parsers.telstra import HtmlParserTelstra1, HtmlParserTelstra2
4141
from circuit_maintenance_parser.parsers.turkcell import HtmlParserTurkcell1
4242
from circuit_maintenance_parser.parsers.verizon import HtmlParserVerizon1
43+
from circuit_maintenance_parser.parsers.windstream import HtmlParserWindstream1
4344
from circuit_maintenance_parser.parsers.zayo import HtmlParserZayo1, SubjectParserZayo1
4445
from circuit_maintenance_parser.processor import CombinedProcessor, GenericProcessor, SimpleProcessor
4546
from circuit_maintenance_parser.utils import rgetattr
@@ -150,22 +151,38 @@ def get_maintenances(self, data: NotificationData) -> Iterable[Maintenance]:
150151
@classmethod
151152
def get_default_organizer(cls) -> str:
152153
"""Expose default_organizer as class attribute."""
153-
return cls._default_organizer.get_default() # type: ignore
154+
try:
155+
return cls._default_organizer.get_default() # type: ignore
156+
except AttributeError:
157+
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
158+
return cls._default_organizer
154159

155160
@classmethod
156161
def get_default_processors(cls) -> List[GenericProcessor]:
157162
"""Expose default_processors as class attribute."""
158-
return cls._processors.get_default() # type: ignore
163+
try:
164+
return cls._processors.get_default() # type: ignore
165+
except AttributeError:
166+
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
167+
return cls._processors
159168

160169
@classmethod
161170
def get_default_include_filters(cls) -> Dict[str, List[str]]:
162171
"""Expose include_filter as class attribute."""
163-
return cls._include_filter.get_default() # type: ignore
172+
try:
173+
return cls._include_filter.get_default() # type: ignore
174+
except AttributeError:
175+
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
176+
return cls._include_filter
164177

165178
@classmethod
166179
def get_default_exclude_filters(cls) -> Dict[str, List[str]]:
167180
"""Expose exclude_filter as class attribute."""
168-
return cls._exclude_filter.get_default() # type: ignore
181+
try:
182+
return cls._exclude_filter.get_default() # type: ignore
183+
except AttributeError:
184+
# TODO: This exception handling is required for Pydantic 1.x compatibility. To be removed when the dependency is deprecated.
185+
return cls._exclude_filter
169186

170187
@classmethod
171188
def get_extended_data(cls):
@@ -307,10 +324,13 @@ class GTT(GenericProvider):
307324
"""EXA (formerly GTT) provider custom class."""
308325

309326
# "Planned Work Notification", "Emergency Work Notification"
310-
_include_filter = PrivateAttr({EMAIL_HEADER_SUBJECT: ["Work Notification"]})
327+
_include_filter = PrivateAttr(
328+
{"Icalendar": ["BEGIN"], "ical": ["BEGIN"], EMAIL_HEADER_SUBJECT: ["Work Notification"]}
329+
)
311330

312331
_processors: List[GenericProcessor] = PrivateAttr(
313332
[
333+
SimpleProcessor(data_parsers=[ICal]),
314334
CombinedProcessor(data_parsers=[EmailDateParser, HtmlParserGTT1]),
315335
]
316336
)
@@ -449,6 +469,17 @@ class Verizon(GenericProvider):
449469
_default_organizer = PrivateAttr("NO-REPLY-sched-maint@EMEA.verizonbusiness.com")
450470

451471

472+
class Windstream(GenericProvider):
473+
"""Windstream provider custom class."""
474+
475+
_processors: List[GenericProcessor] = PrivateAttr(
476+
[
477+
CombinedProcessor(data_parsers=[EmailDateParser, HtmlParserWindstream1]),
478+
]
479+
)
480+
_default_organizer = PrivateAttr("wci.maintenance.notifications@windstream.com")
481+
482+
452483
class Zayo(GenericProvider):
453484
"""Zayo provider custom class."""
454485

circuit_maintenance_parser/utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
from typing import Tuple, Dict, Union
55
import csv
6+
import datetime
7+
import pytz
68

79
from geopy.exc import GeocoderUnavailable, GeocoderTimedOut, GeocoderServiceError # type: ignore
810
from geopy.geocoders import Nominatim # type: ignore
@@ -128,6 +130,52 @@ def city_timezone(self, city: str) -> str:
128130
raise ParserError("Timezone resolution not properly initalized.")
129131

130132

133+
def convert_timezone(time_str):
134+
"""
135+
Converts a string representing a date/time in the format 'MM/DD/YY HH:MM Timezone' to a datetime object in UTC.
136+
137+
Args:
138+
time_str (str): A string representing a date/time followed by a timezone abbreviation.
139+
140+
Returns:
141+
datetime: A datetime object representing the converted date/time in UTC.
142+
143+
Example:
144+
convert_timezone("01/20/24 06:00 ET")
145+
"""
146+
# Convert timezone abbreviation to timezone string for pytz.
147+
timezone_mapping = {
148+
"ET": "US/Eastern",
149+
"CT": "US/Central",
150+
"MT": "US/Mountain",
151+
"PT": "US/Pacific"
152+
# Add more mappings as needed
153+
}
154+
155+
datetime_str, tz_abbr = time_str.rsplit(maxsplit=1)
156+
# Parse the datetime string
157+
dt_time = datetime.datetime.strptime(datetime_str, "%m/%d/%y %H:%M")
158+
159+
timezone = timezone_mapping.get(tz_abbr)
160+
if timezone is None:
161+
try:
162+
# Get the timezone object
163+
tz_zone = pytz.timezone(tz_abbr)
164+
except ValueError as exc:
165+
raise ValueError("Timezone not found: " + str(exc)) # pylint: disable=raise-missing-from
166+
else:
167+
# Get the timezone object
168+
tz_zone = pytz.timezone(timezone)
169+
170+
# Convert to the specified timezone
171+
dt_time = tz_zone.localize(dt_time)
172+
173+
# Convert to UTC
174+
dt_utc = dt_time.astimezone(pytz.utc)
175+
176+
return dt_utc
177+
178+
131179
def rgetattr(obj, attr):
132180
"""Recursive GetAttr to look for nested attributes."""
133181
nested_value = getattr(obj, attr)

0 commit comments

Comments
 (0)