updates
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# -*- mode: sh; -*-
|
||||
pyversion=$(head .python-version)
|
||||
pyenv=coned_rtu
|
||||
use pyenv ${pyversion}
|
||||
@@ -0,0 +1,7 @@
|
||||
[flake8]
|
||||
exclude =
|
||||
.direnv,
|
||||
.git,
|
||||
__pycache__,
|
||||
build,
|
||||
dist
|
||||
@@ -0,0 +1,22 @@
|
||||
default_stages: [commit]
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.8b0
|
||||
hooks:
|
||||
- id: black
|
||||
name: black
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.9.2
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: [--max-line-length=120]
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
- id: end-of-file-fixer
|
||||
- id: mixed-line-ending
|
||||
args: ["--fix=lf"]
|
||||
- id: requirements-txt-fixer
|
||||
- id: trailing-whitespace
|
||||
@@ -0,0 +1 @@
|
||||
3.9.6
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM selenium/standalone-chrome
|
||||
|
||||
USER root
|
||||
RUN apt-get update && apt-get upgrade -yq
|
||||
RUN apt-get install python3 python3-pip -y
|
||||
USER seluser
|
||||
|
||||
WORKDIR /home/seluser/coned-rtu
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
COPY coned_rtu .
|
||||
ENTRYPOINT ["python3", "main.py"]
|
||||
@@ -0,0 +1,4 @@
|
||||
# flake8: noqa
|
||||
from .coned import Coned, LoginFailedException, json_to_readings
|
||||
from .reading import Reading
|
||||
from .store import BucketList, OverlappingReadingError
|
||||
@@ -0,0 +1,199 @@
|
||||
import functools
|
||||
import json
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
import pyotp
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from .reading import Reading
|
||||
|
||||
CONED_LOGIN_URL = "https://www.coned.com/en/login"
|
||||
CONED_USAGE_URL = (
|
||||
"https://www.coned.com/en/accounts-billing/dashboard?tab1=billingandusage-1"
|
||||
)
|
||||
|
||||
DEFAULT_TIMEOUT = 60
|
||||
|
||||
|
||||
class LoginFailedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _screenshot_failure(f):
|
||||
"""Saves a screenshot if an error occurs. Only decorate Coned instance
|
||||
functions, because we assume presence of self."""
|
||||
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
self = args[0]
|
||||
self.save_screenshot("error.png")
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def json_to_readings(usage_json):
|
||||
readings = []
|
||||
usage = json.loads(usage_json)
|
||||
for read in usage["reads"]:
|
||||
# Opower gives readings with null value for intervals that don't have data
|
||||
# yet, so skip them.
|
||||
if read["value"] is None:
|
||||
continue
|
||||
|
||||
reading = Reading(
|
||||
isoparse(read["startTime"]),
|
||||
isoparse(read["endTime"]),
|
||||
usage["unit"],
|
||||
read["value"],
|
||||
)
|
||||
readings.append(reading)
|
||||
return readings
|
||||
|
||||
|
||||
class Coned:
|
||||
def __init__(self, user, password, totp, account_id, meter, maid=""):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.totp = totp
|
||||
self.account_id = account_id
|
||||
self.meter = meter
|
||||
self.maid = maid
|
||||
|
||||
options = webdriver.ChromeOptions()
|
||||
options.add_argument("--headless")
|
||||
|
||||
# this one also solves some crashes sometimes
|
||||
# https://stackoverflow.com/a/50725918
|
||||
options.add_argument("--no-sandbox")
|
||||
|
||||
# this option can sometimes be necessary when running in docker, but
|
||||
# can have a negative impact on performance
|
||||
# https://stackoverflow.com/a/53970825
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
self.driver = webdriver.Chrome(chrome_options=options)
|
||||
|
||||
def opower_usage_url(self):
|
||||
return f"https://cned.opower.com/ei/edge/apis/cws-real-time-ami-v1/cws/cned/accounts/{self.account_id}/meters/{self.meter}/usage" # noqa
|
||||
|
||||
def save_screenshot(self, filename):
|
||||
"""
|
||||
Saves a 1080p screenshot of the page with the given filename in
|
||||
the screenshots folder. Doesn't reset the window size.
|
||||
"""
|
||||
path = f"screenshots/{filename}.png"
|
||||
self.driver.set_window_size(1920, 1080)
|
||||
self.driver.save_screenshot(path)
|
||||
|
||||
@_screenshot_failure
|
||||
def login(self):
|
||||
# Try to load the Billing and Usage page. If we find ourselves at the
|
||||
# login page, then we need to login. If not, we have nothing to do.
|
||||
self.driver.get(CONED_USAGE_URL)
|
||||
if not self.at_login_page():
|
||||
return
|
||||
|
||||
# Submit the login form
|
||||
self.driver.find_element_by_id("form-login-email").send_keys(self.user)
|
||||
self.driver.find_element_by_id("form-login-password").send_keys(self.password)
|
||||
self.driver.find_element_by_id("form-login-password").submit()
|
||||
|
||||
# Wait for login form to get to 2FA step.
|
||||
try:
|
||||
tfa_field = WebDriverWait(self.driver, DEFAULT_TIMEOUT).until(
|
||||
EC.element_to_be_clickable((By.ID, "form-login-mfa-code"))
|
||||
)
|
||||
except TimeoutException as e:
|
||||
# If it times out, it's probably due to bad credentials.
|
||||
if self.is_bad_login():
|
||||
raise LoginFailedException
|
||||
else:
|
||||
raise e
|
||||
|
||||
# Submit 2FA form
|
||||
totp = pyotp.TOTP(self.totp)
|
||||
tfa_field.send_keys(totp.now())
|
||||
tfa_field.submit()
|
||||
|
||||
# Wait for dashboard to appear
|
||||
WebDriverWait(self.driver, DEFAULT_TIMEOUT).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//button[@data-value='overview']"))
|
||||
)
|
||||
|
||||
# If prompted to select service address, use maid id to resolve
|
||||
try:
|
||||
must_select = WebDriverWait(self.driver, 2).until( # noqa: F841
|
||||
EC.presence_of_element_located(
|
||||
(
|
||||
By.XPATH,
|
||||
"//div[contains(@class, 'account-focus__accounts-container')]",
|
||||
)
|
||||
)
|
||||
)
|
||||
account_button = WebDriverWait(self.driver, 5).until(
|
||||
EC.element_to_be_clickable(
|
||||
(By.XPATH, f"//button[@data-maid='{self.maid}']")
|
||||
)
|
||||
)
|
||||
account_button.click()
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
@_screenshot_failure
|
||||
def get_usage(self):
|
||||
self.driver.get(CONED_USAGE_URL)
|
||||
|
||||
# Go to "real time usage"
|
||||
rtu_button = WebDriverWait(self.driver, DEFAULT_TIMEOUT).until(
|
||||
EC.element_to_be_clickable(
|
||||
(By.XPATH, "//button[@data-value='sectionRealTimeData']")
|
||||
)
|
||||
)
|
||||
rtu_button.click()
|
||||
|
||||
# Wait for "Download your real-time usage" in the iframe to appear so
|
||||
# that it triggers authentication on the opower side
|
||||
iframe = WebDriverWait(self.driver, DEFAULT_TIMEOUT).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//*[@id='sectionRealTimeData']/iframe")
|
||||
)
|
||||
)
|
||||
self.driver.switch_to.frame(iframe)
|
||||
WebDriverWait(self.driver, DEFAULT_TIMEOUT).until(
|
||||
EC.element_to_be_clickable((By.ID, "download-link"))
|
||||
)
|
||||
|
||||
# Get the usage from opower
|
||||
self.driver.get(self.opower_usage_url())
|
||||
return self.driver.find_element_by_tag_name("body").text
|
||||
|
||||
@_screenshot_failure
|
||||
def at_login_page(self):
|
||||
"""
|
||||
at_login_page returns whether the driver is at the ConEd login
|
||||
page by looking for the login form.
|
||||
"""
|
||||
try:
|
||||
self.driver.find_element_by_id("form-login-email")
|
||||
return True
|
||||
except NoSuchElementException:
|
||||
return False
|
||||
|
||||
@_screenshot_failure
|
||||
def is_bad_login(self):
|
||||
"""
|
||||
is_bad_login returns whether there is a failed login indicator
|
||||
on the page.
|
||||
"""
|
||||
bad_login = self.driver.find_element_by_class_name(
|
||||
"login-form__container-error"
|
||||
)
|
||||
return "you entered does not match our records" in bad_login.text
|
||||
@@ -0,0 +1,64 @@
|
||||
ENERGY_UNITS = ("wh", "kwh")
|
||||
|
||||
|
||||
class Reading:
|
||||
"""
|
||||
Reading represents an energy reading over an interval. It is essentially
|
||||
the same in concept as an ESPI IntervalBlock.
|
||||
"""
|
||||
|
||||
def __init__(self, start_time, end_time, unit, value):
|
||||
if start_time >= end_time:
|
||||
raise ValueError("end_time must be after start_time.")
|
||||
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
|
||||
if unit.lower() not in ENERGY_UNITS:
|
||||
raise ValueError("Invalid unit: use only Wh or kWh.")
|
||||
|
||||
if unit.lower() == "kwh":
|
||||
value = value * 1000
|
||||
|
||||
self.wh = value
|
||||
|
||||
@classmethod
|
||||
def combine(cls, a, b):
|
||||
return cls(
|
||||
a.start_time if a.start_time < b.start_time else b.start_time,
|
||||
a.end_time if a.end_time > b.end_time else b.end_time,
|
||||
"wh",
|
||||
a.wh + b.wh,
|
||||
)
|
||||
|
||||
def duration(self):
|
||||
return self.end_time - self.start_time
|
||||
|
||||
def overlaps(self, other):
|
||||
return (
|
||||
(self.start_time < other.start_time and self.end_time > other.start_time)
|
||||
or (other.start_time < self.start_time and other.end_time > self.start_time)
|
||||
or (self.start_time == other.start_time or self.end_time == other.end_time)
|
||||
)
|
||||
|
||||
def hash_bucket(self):
|
||||
return str(self.start_time.date())
|
||||
|
||||
def __eq__(self, them):
|
||||
return (
|
||||
isinstance(them, type(self))
|
||||
and self.__equality_set__() == them.__equality_set__()
|
||||
)
|
||||
|
||||
def __equality_set__(self):
|
||||
"""Return the set of values that define my difference from others."""
|
||||
return (self.start_time, self.end_time, self.wh)
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.start_time, self.end_time, self.wh))
|
||||
|
||||
def __str__(self):
|
||||
return f"Reading @ {self.start_time} ({self.duration()}): {self.wh} Wh"
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
@@ -0,0 +1,52 @@
|
||||
from .reading import Reading
|
||||
|
||||
|
||||
class OverlappingReadingError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class BucketList:
|
||||
"""
|
||||
BucketList implements an ordered list using buckets whose keys are
|
||||
determined by the Reading's hash_bucket function. When iterating,
|
||||
BucketList yields in order of bucket key, then the ordered list in each
|
||||
bucket.
|
||||
"""
|
||||
|
||||
def __init__(self, *args):
|
||||
# dict to hold buckets -> ordered lists
|
||||
self._dict = {}
|
||||
# ordered list of bucket keys, for iteration
|
||||
self._keys = []
|
||||
|
||||
def to_list(self):
|
||||
out = []
|
||||
for bucket in self._keys:
|
||||
for r in self._dict[bucket]:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
def get_bucket(self, key: str):
|
||||
# create the bucket if it doesn't exist already
|
||||
if key not in self._dict:
|
||||
self._keys.append(key)
|
||||
self._keys.sort()
|
||||
self._dict[key] = []
|
||||
|
||||
return self._dict[key]
|
||||
|
||||
def insert(self, reading: Reading):
|
||||
bucket = self.get_bucket(reading.hash_bucket())
|
||||
|
||||
# If the new reading is identical to an existing one, deduplicate it
|
||||
# and consider it successful. If the new reading overlaps with an
|
||||
# existing reading, disallow it.
|
||||
for r in bucket:
|
||||
if reading == r:
|
||||
return
|
||||
if reading.overlaps(r):
|
||||
raise OverlappingReadingError
|
||||
|
||||
# incredibly lazy implementation, could insert sorted
|
||||
bucket.append(reading)
|
||||
bucket.sort(key=lambda x: x.start_time)
|
||||
@@ -0,0 +1 @@
|
||||
pytest==6.2.4
|
||||
@@ -0,0 +1,8 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
scraper:
|
||||
build: .
|
||||
volumes:
|
||||
- ./:/home/seluser/coned-rtu
|
||||
entrypoint: ["python3", "main.py", "dev.ini"]
|
||||
@@ -0,0 +1,25 @@
|
||||
import sys
|
||||
|
||||
from configobj import ConfigObj
|
||||
|
||||
from coned_rtu import Coned
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <config file>")
|
||||
sys.exit(1)
|
||||
config = ConfigObj(sys.argv[1])
|
||||
|
||||
CONED_USER = config.get("CONED_USER")
|
||||
CONED_PASS = config.get("CONED_PASS")
|
||||
CONED_TOTP = config.get("CONED_TOTP")
|
||||
OPOWER_ACCOUNT_ID = config.get("OPOWER_ACCOUNT_ID")
|
||||
OPOWER_METER = config.get("OPOWER_METER")
|
||||
CONED_MAID = config.get("CONED_MAID")
|
||||
|
||||
coned = Coned(
|
||||
CONED_USER, CONED_PASS, CONED_TOTP, OPOWER_ACCOUNT_ID, OPOWER_METER, CONED_MAID
|
||||
)
|
||||
|
||||
coned.login()
|
||||
usage_json = coned.get_usage()
|
||||
print(usage_json)
|
||||
@@ -0,0 +1,6 @@
|
||||
configobj==5.0.6
|
||||
pre-commit==2.14.1
|
||||
pyotp==2.3.0
|
||||
python-dateutil==2.8.1
|
||||
python-dotenv==0.11.0
|
||||
selenium==3.141.0
|
||||
@@ -0,0 +1,17 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="coned_rtu",
|
||||
version="0.1",
|
||||
description="Getting real-time usage reports from Con Edison",
|
||||
packages=["coned_rtu"],
|
||||
zip_safe=False,
|
||||
install_requires=[
|
||||
"configobj==5.0.6",
|
||||
"pre-commit==2.14.1",
|
||||
"pyotp==2.3.0",
|
||||
"python-dateutil==2.8.1",
|
||||
"python-dotenv==0.11.0",
|
||||
"selenium==3.141.0",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import unittest
|
||||
|
||||
from coned_rtu import Reading, json_to_readings
|
||||
|
||||
tz = timezone(timedelta(hours=-4))
|
||||
|
||||
|
||||
class TestConed(unittest.TestCase):
|
||||
def test_json_to_readings(self):
|
||||
with open("tests/usage.json") as f:
|
||||
j = f.read()
|
||||
got = json_to_readings(j)
|
||||
want = [
|
||||
Reading(
|
||||
datetime(2021, 8, 29, 0, 30, 0, tzinfo=tz),
|
||||
datetime(2021, 8, 29, 0, 45, 0, tzinfo=tz),
|
||||
"wh",
|
||||
110.5,
|
||||
),
|
||||
Reading(
|
||||
datetime(2021, 8, 29, 0, 45, 0, tzinfo=tz),
|
||||
datetime(2021, 8, 29, 1, 0, 0, tzinfo=tz),
|
||||
"wh",
|
||||
121.5,
|
||||
),
|
||||
]
|
||||
self.assertEqual(got, want)
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import unittest
|
||||
|
||||
from coned_rtu import Reading
|
||||
|
||||
|
||||
class TestReading(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sometime = datetime(2010, 12, 25, 14, 30, 0, tzinfo=timezone.utc)
|
||||
self.later = datetime(2010, 12, 26, 14, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_start_time_must_be_before_end_time(self):
|
||||
# start and end time the same
|
||||
with self.assertRaises(ValueError):
|
||||
Reading(self.sometime, self.sometime, "wh", 1)
|
||||
|
||||
# start time after end time
|
||||
with self.assertRaises(ValueError):
|
||||
Reading(self.later, self.sometime, "wh", 1)
|
||||
|
||||
def test_enforces_units(self):
|
||||
# only wh or kwh supported
|
||||
with self.assertRaises(ValueError):
|
||||
Reading(self.sometime, self.later, "mwh", 1)
|
||||
|
||||
# incorrect capitalization is okay
|
||||
Reading(self.sometime, self.later, "Wh", 1)
|
||||
|
||||
def test_duration(self):
|
||||
r = Reading(self.sometime, self.later, "wh", 1)
|
||||
self.assertEqual(r.duration(), timedelta(hours=24))
|
||||
|
||||
def test_overlaps(self):
|
||||
t1 = self.sometime
|
||||
t2 = t1 + timedelta(hours=1)
|
||||
t3 = t2 + timedelta(hours=1)
|
||||
t4 = t3 + timedelta(hours=1)
|
||||
|
||||
# one within the other
|
||||
a = Reading(t1, t4, "wh", 1)
|
||||
b = Reading(t2, t3, "wh", 1)
|
||||
self.assertTrue(a.overlaps(b))
|
||||
self.assertTrue(b.overlaps(a))
|
||||
|
||||
# one ends during the other
|
||||
a = Reading(t1, t3, "wh", 1)
|
||||
b = Reading(t2, t4, "wh", 1)
|
||||
self.assertTrue(a.overlaps(b))
|
||||
self.assertTrue(b.overlaps(a))
|
||||
|
||||
# same start, one is shorter than the other
|
||||
a = Reading(t1, t3, "wh", 1)
|
||||
b = Reading(t1, t2, "wh", 1)
|
||||
self.assertTrue(a.overlaps(b))
|
||||
self.assertTrue(b.overlaps(a))
|
||||
|
||||
# same end, one is shorter than the other
|
||||
a = Reading(t1, t3, "wh", 1)
|
||||
b = Reading(t2, t3, "wh", 1)
|
||||
self.assertTrue(a.overlaps(b))
|
||||
self.assertTrue(b.overlaps(a))
|
||||
|
||||
# not overlapping
|
||||
a = Reading(t1, t2, "wh", 1)
|
||||
b = Reading(t2, t3, "wh", 1)
|
||||
self.assertFalse(a.overlaps(b))
|
||||
self.assertFalse(b.overlaps(a))
|
||||
|
||||
def test_hash_bucket(self):
|
||||
a = Reading(self.sometime, self.later, "wh", 1)
|
||||
b = Reading(self.sometime, self.later, "wh", 2)
|
||||
c = Reading(
|
||||
self.sometime + timedelta(hours=24),
|
||||
self.later + timedelta(hours=24),
|
||||
"wh",
|
||||
1,
|
||||
)
|
||||
self.assertEqual(a.hash_bucket(), b.hash_bucket())
|
||||
self.assertNotEqual(a.hash_bucket(), c.hash_bucket())
|
||||
|
||||
def test_equality(self):
|
||||
r = Reading(self.sometime, self.later, "wh", 1)
|
||||
|
||||
# times mismatch
|
||||
bad_time = self.sometime + timedelta(seconds=10)
|
||||
|
||||
other = Reading(bad_time, self.later, "wh", 1)
|
||||
self.assertNotEqual(r, other)
|
||||
|
||||
other = Reading(self.sometime, bad_time, "wh", 1)
|
||||
self.assertNotEqual(r, other)
|
||||
|
||||
# energy mismatch
|
||||
other = Reading(self.sometime, self.later, "wh", 1.0000001)
|
||||
self.assertNotEqual(r, other)
|
||||
|
||||
# fully equal
|
||||
other = Reading(self.sometime, self.later, "wh", 1)
|
||||
self.assertEqual(r, other)
|
||||
|
||||
def test_hashing(self):
|
||||
# unequal
|
||||
a = Reading(self.sometime, self.later, "wh", 1)
|
||||
b = Reading(self.sometime, self.later, "wh", 2)
|
||||
self.assertNotEqual(hash(a), hash(b))
|
||||
|
||||
# equal
|
||||
b = Reading(self.sometime, self.later, "wh", 1)
|
||||
self.assertEqual(hash(a), hash(b))
|
||||
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import unittest
|
||||
|
||||
from coned_rtu import Reading, BucketList, OverlappingReadingError
|
||||
|
||||
|
||||
class TestStore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sometime = datetime(2010, 12, 25, 14, 30, 0, tzinfo=timezone.utc)
|
||||
self.later = datetime(2010, 12, 26, 14, 30, 0, tzinfo=timezone.utc)
|
||||
self.hour = timedelta(hours=1)
|
||||
self.day = timedelta(hours=24)
|
||||
|
||||
def test(self):
|
||||
bl = BucketList()
|
||||
t1 = self.sometime
|
||||
t2 = t1 + self.hour
|
||||
t3 = t2 + self.hour
|
||||
t4 = t3 + self.day
|
||||
|
||||
b = Reading(t2, t3, "wh", 1)
|
||||
bl.insert(b)
|
||||
|
||||
a = Reading(t1, t2, "wh", 1)
|
||||
bl.insert(a)
|
||||
|
||||
# inserting an overlapping reading is an error
|
||||
bad = Reading(t2, t2 + timedelta(minutes=30), "wh", 1)
|
||||
with self.assertRaises(OverlappingReadingError):
|
||||
bl.insert(bad)
|
||||
|
||||
# inserting an identical reading results in no change
|
||||
bl.insert(a)
|
||||
|
||||
# inserted readings are sorted
|
||||
got = bl.get_bucket(a.hash_bucket())
|
||||
want = [a, b]
|
||||
self.assertEqual(got, want)
|
||||
|
||||
# to_list returns a list in sorted order
|
||||
c = Reading(t3, t4, "wh", 3)
|
||||
bl.insert(c)
|
||||
got = bl.to_list()
|
||||
want = [a, b, c]
|
||||
self.assertEqual(got, want)
|
||||
|
||||
def test_get_bucket_makes_bucket_if_not_exist(self):
|
||||
bl = BucketList()
|
||||
day = "2021-08-30"
|
||||
_ = bl.get_bucket(day)
|
||||
self.assertEqual(len(bl._dict), 1)
|
||||
self.assertEqual(list(bl._dict.keys())[0], day)
|
||||
Reference in New Issue
Block a user