Fixed PLW1514 (unspecified-encoding): `open` in text mode without explicit `encoding` argument

This commit is contained in:
Teal Dulcet 2023-12-23 05:07:25 -08:00 committed by Joshua Tauberer
parent a02b59d4e4
commit 0e9193651d
12 changed files with 44 additions and 44 deletions

View File

@ -22,7 +22,7 @@ class AuthService:
def init_system_api_key(self): def init_system_api_key(self):
"""Write an API key to a local file so local processes can use the API""" """Write an API key to a local file so local processes can use the API"""
with open(self.key_path) as file: with open(self.key_path, encoding='utf-8') as file:
self.key = file.read() self.key = file.read()
def authenticate(self, request, env, login_only=False, logout=False): def authenticate(self, request, env, login_only=False, logout=False):

View File

@ -185,7 +185,7 @@ def get_passphrase(env):
# only needs to be 43 base64-characters to match AES256's key # only needs to be 43 base64-characters to match AES256's key
# length of 32 bytes. # length of 32 bytes.
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
with open(os.path.join(backup_root, 'secret_key.txt')) as f: with open(os.path.join(backup_root, 'secret_key.txt'), encoding="utf-8") as f:
passphrase = f.readline().strip() passphrase = f.readline().strip()
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!") if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
@ -580,7 +580,7 @@ def get_backup_config(env, for_save=False, for_ui=False):
# Merge in anything written to custom.yaml. # Merge in anything written to custom.yaml.
try: try:
with open(os.path.join(backup_root, 'custom.yaml')) as f: with open(os.path.join(backup_root, 'custom.yaml'), encoding="utf-8") as f:
custom_config = rtyaml.load(f) custom_config = rtyaml.load(f)
if not isinstance(custom_config, dict): raise ValueError # caught below if not isinstance(custom_config, dict): raise ValueError # caught below
config.update(custom_config) config.update(custom_config)
@ -606,14 +606,14 @@ def get_backup_config(env, for_save=False, for_ui=False):
config["target"] = "file://" + config["file_target_directory"] config["target"] = "file://" + config["file_target_directory"]
ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub') ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub')
if os.path.exists(ssh_pub_key): if os.path.exists(ssh_pub_key):
with open(ssh_pub_key) as f: with open(ssh_pub_key, encoding="utf-8") as f:
config["ssh_pub_key"] = f.read() config["ssh_pub_key"] = f.read()
return config return config
def write_backup_config(env, newconfig): def write_backup_config(env, newconfig):
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f: with open(os.path.join(backup_root, 'custom.yaml'), "w", encoding="utf-8") as f:
f.write(rtyaml.dump(newconfig)) f.write(rtyaml.dump(newconfig))
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -47,7 +47,7 @@ def read_password():
return first return first
def setup_key_auth(mgmt_uri): def setup_key_auth(mgmt_uri):
with open('/var/lib/mailinabox/api.key') as f: with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f:
key = f.read().strip() key = f.read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler() auth_handler = urllib.request.HTTPBasicAuthHandler()

View File

@ -36,7 +36,7 @@ except OSError:
# for generating CSRs we need a list of country codes # for generating CSRs we need a list of country codes
csr_country_codes = [] csr_country_codes = []
with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv")) as f: with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv"), encoding="utf-8") as f:
for line in f: for line in f:
if line.strip() == "" or line.startswith("#"): continue if line.strip() == "" or line.startswith("#"): continue
code, name = line.strip().split("\t")[0:2] code, name = line.strip().split("\t")[0:2]

View File

@ -295,7 +295,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
# Append the DKIM TXT record to the zone as generated by OpenDKIM. # Append the DKIM TXT record to the zone as generated by OpenDKIM.
# Skip if the user has set a DKIM record already. # Skip if the user has set a DKIM record already.
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
with open(opendkim_record_file) as orf: with open(opendkim_record_file, encoding="utf-8") as orf:
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S) m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
val = "".join(re.findall(r'"([^"]+)"', m.group(2))) val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "): if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
@ -452,7 +452,7 @@ def build_sshfp_records():
# specify that port to sshkeyscan. # specify that port to sshkeyscan.
port = 22 port = 22
with open('/etc/ssh/sshd_config') as f: with open('/etc/ssh/sshd_config', encoding="utf-8") as f:
for line in f: for line in f:
s = line.rstrip().split() s = line.rstrip().split()
if len(s) == 2 and s[0] == 'Port': if len(s) == 2 and s[0] == 'Port':
@ -547,7 +547,7 @@ $TTL 86400 ; default time to live
# We've signed the domain. Check if we are close to the expiration # We've signed the domain. Check if we are close to the expiration
# time of the signature. If so, we'll force a bump of the serial # time of the signature. If so, we'll force a bump of the serial
# number so we can re-sign it. # number so we can re-sign it.
with open(zonefile + ".signed") as f: with open(zonefile + ".signed", encoding="utf-8") as f:
signed_zone = f.read() signed_zone = f.read()
expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone) expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone)
if len(expiration_times) == 0: if len(expiration_times) == 0:
@ -566,7 +566,7 @@ $TTL 86400 ; default time to live
if os.path.exists(zonefile): if os.path.exists(zonefile):
# If the zone already exists, is different, and has a later serial number, # If the zone already exists, is different, and has a later serial number,
# increment the number. # increment the number.
with open(zonefile) as f: with open(zonefile, encoding="utf-8") as f:
existing_zone = f.read() existing_zone = f.read()
m = re.search(r"(\d+)\s*;\s*serial number", existing_zone) m = re.search(r"(\d+)\s*;\s*serial number", existing_zone)
if m: if m:
@ -590,7 +590,7 @@ $TTL 86400 ; default time to live
zone = zone.replace("__SERIAL__", serial) zone = zone.replace("__SERIAL__", serial)
# Write the zone file. # Write the zone file.
with open(zonefile, "w") as f: with open(zonefile, "w", encoding="utf-8") as f:
f.write(zone) f.write(zone)
return True # file is updated return True # file is updated
@ -603,7 +603,7 @@ def get_dns_zonefile(zone, env):
raise ValueError("%s is not a domain name that corresponds to a zone." % zone) raise ValueError("%s is not a domain name that corresponds to a zone." % zone)
nsd_zonefile = "/etc/nsd/zones/" + fn nsd_zonefile = "/etc/nsd/zones/" + fn
with open(nsd_zonefile) as f: with open(nsd_zonefile, encoding="utf-8") as f:
return f.read() return f.read()
######################################################################## ########################################################################
@ -631,13 +631,13 @@ zone:
# Check if the file is changing. If it isn't changing, # Check if the file is changing. If it isn't changing,
# return False to flag that no change was made. # return False to flag that no change was made.
if os.path.exists(nsd_conf_file): if os.path.exists(nsd_conf_file):
with open(nsd_conf_file) as f: with open(nsd_conf_file, encoding="utf-8") as f:
if f.read() == nsdconf: if f.read() == nsdconf:
return False return False
# Write out new contents and return True to signal that # Write out new contents and return True to signal that
# configuration changed. # configuration changed.
with open(nsd_conf_file, "w") as f: with open(nsd_conf_file, "w", encoding="utf-8") as f:
f.write(nsdconf) f.write(nsdconf)
return True return True
@ -672,7 +672,7 @@ def hash_dnssec_keys(domain, env):
for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)): for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)):
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private") oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private")
keydata.extend((keytype, keyfn)) keydata.extend((keytype, keyfn))
with open(oldkeyfn) as fr: with open(oldkeyfn, encoding="utf-8") as fr:
keydata.append( fr.read() ) keydata.append( fr.read() )
keydata = "".join(keydata).encode("utf8") keydata = "".join(keydata).encode("utf8")
return hashlib.sha1(keydata).hexdigest() return hashlib.sha1(keydata).hexdigest()
@ -700,12 +700,12 @@ def sign_zone(domain, zonefile, env):
# Use os.umask and open().write() to securely create a copy that only # Use os.umask and open().write() to securely create a copy that only
# we (root) can read. # we (root) can read.
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext) oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext)
with open(oldkeyfn) as fr: with open(oldkeyfn, encoding="utf-8") as fr:
keydata = fr.read() keydata = fr.read()
keydata = keydata.replace("_domain_", domain) keydata = keydata.replace("_domain_", domain)
prev_umask = os.umask(0o77) # ensure written file is not world-readable prev_umask = os.umask(0o77) # ensure written file is not world-readable
try: try:
with open(newkeyfn + ext, "w") as fw: with open(newkeyfn + ext, "w", encoding="utf-8") as fw:
fw.write(keydata) fw.write(keydata)
finally: finally:
os.umask(prev_umask) # other files we write should be world-readable os.umask(prev_umask) # other files we write should be world-readable
@ -739,7 +739,7 @@ def sign_zone(domain, zonefile, env):
# be used, so we'll pre-generate all for each key. One DS record per line. Only one # be used, so we'll pre-generate all for each key. One DS record per line. Only one
# needs to actually be deployed at the registrar. We'll select the preferred one # needs to actually be deployed at the registrar. We'll select the preferred one
# in the status checks. # in the status checks.
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f: with open("/etc/nsd/zones/" + zonefile + ".ds", "w", encoding="utf-8") as f:
for key in ksk_keys: for key in ksk_keys:
for digest_type in ('1', '2', '4'): for digest_type in ('1', '2', '4'):
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds", rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
@ -794,12 +794,12 @@ def write_opendkim_tables(domains, env):
for filename, content in config.items(): for filename, content in config.items():
# Don't write the file if it doesn't need an update. # Don't write the file if it doesn't need an update.
if os.path.exists("/etc/opendkim/" + filename): if os.path.exists("/etc/opendkim/" + filename):
with open("/etc/opendkim/" + filename) as f: with open("/etc/opendkim/" + filename, encoding="utf-8") as f:
if f.read() == content: if f.read() == content:
continue continue
# The contents needs to change. # The contents needs to change.
with open("/etc/opendkim/" + filename, "w") as f: with open("/etc/opendkim/" + filename, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
did_update = True did_update = True
@ -811,7 +811,7 @@ def write_opendkim_tables(domains, env):
def get_custom_dns_config(env, only_real_records=False): def get_custom_dns_config(env, only_real_records=False):
try: try:
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')) as f: with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), encoding="utf-8") as f:
custom_dns = rtyaml.load(f) custom_dns = rtyaml.load(f)
if not isinstance(custom_dns, dict): raise ValueError # caught below if not isinstance(custom_dns, dict): raise ValueError # caught below
except: except:
@ -893,7 +893,7 @@ def write_custom_dns_config(config, env):
# Write. # Write.
config_yaml = rtyaml.dump(dns) config_yaml = rtyaml.dump(dns)
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f: with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w", encoding="utf-8") as f:
f.write(config_yaml) f.write(config_yaml)
def set_custom_dns_record(qname, rtype, value, action, env): def set_custom_dns_record(qname, rtype, value, action, env):

View File

@ -585,7 +585,7 @@ def scan_postfix_submission_line(date, log, collector):
def readline(filename): def readline(filename):
""" A generator that returns the lines of a file """ A generator that returns the lines of a file
""" """
with open(filename, errors='replace') as file: with open(filename, errors='replace', encoding='utf-8') as file:
while True: while True:
line = file.readline() line = file.readline()
if not line: if not line:

View File

@ -212,7 +212,7 @@ def check_ssh_password(env, output):
# the configuration file. # the configuration file.
if not os.path.exists("/etc/ssh/sshd_config"): if not os.path.exists("/etc/ssh/sshd_config"):
return return
with open("/etc/ssh/sshd_config") as f: with open("/etc/ssh/sshd_config", encoding="utf-8") as f:
sshd = f.read() sshd = f.read()
if re.search("\nPasswordAuthentication\\s+yes", sshd) \ if re.search("\nPasswordAuthentication\\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\\s+no", sshd): or not re.search("\nPasswordAuthentication\\s+no", sshd):
@ -582,7 +582,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
expected_ds_records = { } expected_ds_records = { }
ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds' ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds'
if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated. if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated.
with open(ds_file) as f: with open(ds_file, encoding="utf-8") as f:
for rr_ds in f: for rr_ds in f:
rr_ds = rr_ds.rstrip() rr_ds = rr_ds.rstrip()
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ") ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
@ -591,7 +591,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
# record that we suggest using is for the KSK (and that's how the DS records were generated). # record that we suggest using is for the KSK (and that's how the DS records were generated).
# We'll also give the nice name for the key algorithm. # We'll also give the nice name for the key algorithm.
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg])) dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg]))
with open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')) as f: with open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key'), encoding="utf-8") as f:
dnsssec_pubkey = f.read().split("\t")[3].split(" ")[3] dnsssec_pubkey = f.read().split("\t")[3].split(" ")[3]
expected_ds_records[ (ds_keytag, ds_alg, ds_digalg, ds_digest) ] = { expected_ds_records[ (ds_keytag, ds_alg, ds_digalg, ds_digest) ] = {
@ -951,7 +951,7 @@ def run_and_output_changes(env, pool):
# Load previously saved status checks. # Load previously saved status checks.
cache_fn = "/var/cache/mailinabox/status_checks.json" cache_fn = "/var/cache/mailinabox/status_checks.json"
if os.path.exists(cache_fn): if os.path.exists(cache_fn):
with open(cache_fn) as f: with open(cache_fn, encoding="utf-8") as f:
try: try:
prev = json.load(f) prev = json.load(f)
except json.JSONDecodeError: except json.JSONDecodeError:
@ -1007,7 +1007,7 @@ def run_and_output_changes(env, pool):
# Store the current status checks output for next time. # Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True) os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f: with open(cache_fn, "w", encoding="utf-8") as f:
json.dump(cur.buf, f, indent=True) json.dump(cur.buf, f, indent=True)
def normalize_ip(ip): def normalize_ip(ip):

View File

@ -14,13 +14,13 @@ def load_env_vars_from_file(fn):
# Load settings from a KEY=VALUE file. # Load settings from a KEY=VALUE file.
import collections import collections
env = collections.OrderedDict() env = collections.OrderedDict()
with open(fn) as f: with open(fn, encoding="utf-8") as f:
for line in f: for line in f:
env.setdefault(*line.strip().split("=", 1)) env.setdefault(*line.strip().split("=", 1))
return env return env
def save_environment(env): def save_environment(env):
with open("/etc/mailinabox.conf", "w") as f: with open("/etc/mailinabox.conf", "w", encoding="utf-8") as f:
for k, v in env.items(): for k, v in env.items():
f.write(f"{k}={v}\n") f.write(f"{k}={v}\n")
@ -29,14 +29,14 @@ def save_environment(env):
def write_settings(config, env): def write_settings(config, env):
import rtyaml import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml') fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
with open(fn, "w") as f: with open(fn, "w", encoding="utf-8") as f:
f.write(rtyaml.dump(config)) f.write(rtyaml.dump(config))
def load_settings(env): def load_settings(env):
import rtyaml import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml') fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
try: try:
with open(fn) as f: with open(fn, encoding="utf-8") as f:
config = rtyaml.load(f) config = rtyaml.load(f)
if not isinstance(config, dict): raise ValueError # caught below if not isinstance(config, dict): raise ValueError # caught below
return config return config

View File

@ -62,7 +62,7 @@ def get_web_domains_with_root_overrides(env):
root_overrides = { } root_overrides = { }
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml") nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn): if os.path.exists(nginx_conf_custom_fn):
with open(nginx_conf_custom_fn) as f: with open(nginx_conf_custom_fn, encoding='utf-8') as f:
custom_settings = rtyaml.load(f) custom_settings = rtyaml.load(f)
for domain, settings in custom_settings.items(): for domain, settings in custom_settings.items():
for type, value in [('redirect', settings.get('redirects', {}).get('/')), for type, value in [('redirect', settings.get('redirects', {}).get('/')),
@ -77,7 +77,7 @@ def do_web_update(env):
# Helper for reading config files and templates # Helper for reading config files and templates
def read_conf(conf_fn): def read_conf(conf_fn):
with open(os.path.join(os.path.dirname(__file__), "../conf", conf_fn)) as f: with open(os.path.join(os.path.dirname(__file__), "../conf", conf_fn), encoding='utf-8') as f:
return f.read() return f.read()
# Build an nginx configuration file. # Build an nginx configuration file.
@ -112,12 +112,12 @@ def do_web_update(env):
# Did the file change? If not, don't bother writing & restarting nginx. # Did the file change? If not, don't bother writing & restarting nginx.
nginx_conf_fn = "/etc/nginx/conf.d/local.conf" nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
if os.path.exists(nginx_conf_fn): if os.path.exists(nginx_conf_fn):
with open(nginx_conf_fn) as f: with open(nginx_conf_fn, encoding='utf-8') as f:
if f.read() == nginx_conf: if f.read() == nginx_conf:
return "" return ""
# Save the file. # Save the file.
with open(nginx_conf_fn, "w") as f: with open(nginx_conf_fn, "w", encoding='utf-8') as f:
f.write(nginx_conf) f.write(nginx_conf)
# Kick nginx. Since this might be called from the web admin # Kick nginx. Since this might be called from the web admin
@ -155,7 +155,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
hsts = "yes" hsts = "yes"
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml") nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn): if os.path.exists(nginx_conf_custom_fn):
with open(nginx_conf_custom_fn) as f: with open(nginx_conf_custom_fn, encoding='utf-8') as f:
yaml = rtyaml.load(f) yaml = rtyaml.load(f)
if domain in yaml: if domain in yaml:
yaml = yaml[domain] yaml = yaml[domain]

View File

@ -212,7 +212,7 @@ def run_migrations():
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version') migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
migration_id = None migration_id = None
if os.path.exists(migration_id_file): if os.path.exists(migration_id_file):
with open(migration_id_file) as f: with open(migration_id_file, encoding='utf-8') as f:
migration_id = f.read().strip() migration_id = f.read().strip()
if migration_id is None: if migration_id is None:
@ -253,7 +253,7 @@ def run_migrations():
# Write out our current version now. Do this sooner rather than later # Write out our current version now. Do this sooner rather than later
# in case of any problems. # in case of any problems.
with open(migration_id_file, "w") as f: with open(migration_id_file, "w", encoding='utf-8') as f:
f.write(str(ourver) + "\n") f.write(str(ourver) + "\n")
# Delete the legacy location of this field. # Delete the legacy location of this field.

View File

@ -76,7 +76,7 @@ for setting in settings:
found = set() found = set()
buf = "" buf = ""
with open(filename) as f: with open(filename, encoding="utf-8") as f:
input_lines = list(f) input_lines = list(f)
while len(input_lines) > 0: while len(input_lines) > 0:
@ -144,7 +144,7 @@ for i in range(len(settings)):
if not testing: if not testing:
# Write out the new file. # Write out the new file.
with open(filename, "w") as f: with open(filename, "w", encoding="utf-8") as f:
f.write(buf) f.write(buf)
else: else:
# Just print the new file to stdout. # Just print the new file to stdout.

View File

@ -38,7 +38,7 @@ for date, ip in accesses:
# Since logs are rotated, store the statistics permanently in a JSON file. # Since logs are rotated, store the statistics permanently in a JSON file.
# Load in the stats from an existing file. # Load in the stats from an existing file.
if os.path.exists(outfn): if os.path.exists(outfn):
with open(outfn) as f: with open(outfn, encoding="utf-8") as f:
existing_data = json.load(f) existing_data = json.load(f)
for date, count in existing_data: for date, count in existing_data:
if date not in by_date: if date not in by_date:
@ -51,5 +51,5 @@ by_date = sorted(by_date.items())
by_date.pop(-1) by_date.pop(-1)
# Write out. # Write out.
with open(outfn, "w") as f: with open(outfn, "w", encoding="utf-8") as f:
json.dump(by_date, f, sort_keys=True, indent=True) json.dump(by_date, f, sort_keys=True, indent=True)