Skip to content

Commit 51475a8

Browse files
author
Nathan Parsons
committed
Improve error handling and output
- Move 'click.command' to the script rather than the module. - Use exceptions to handle errors and pass them to the script, rather than calling 'sys.exit' from the module. - Use 'warnings.warn' to handle warnings and catch these in the script. - Implement colourised print messages along with the '--nocolour' flag to disable them. - Move message printing to the script rather than logging function, along with logic for '--quiet'. - Set flake8 'max-line-length = 100'. - Bump version number to 0.2 to reflect significant changes.
1 parent 726fb3e commit 51475a8

File tree

6 files changed

+193
-65
lines changed

6 files changed

+193
-65
lines changed

.flake8

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[flake8]
2+
max-line-length=100

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,6 @@ venv.bak/
102102

103103
# mypy
104104
.mypy_cache/
105+
106+
# Ignore personal configuration (contains secrets!)
107+
config.yaml

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ The script can be run manually by calling `godaddy-ddns` with the following opti
5050
- `--config`: Path to the configuration file (default: `/etc/godaddy-ddns/config.yaml`).
5151
- `--force`: Update the IP address regardless of the value in the cache.
5252
- `--quiet`: Don't print messages to `stdout`.
53+
- `--nocolour`: Don't use colour when printing to `stdout`.
5354

5455
### Systemd
5556

bin/godaddy-ddns

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,65 @@
11
#!/usr/bin/env python3
22

3+
import sys
4+
import warnings
5+
6+
import click
7+
38
import godaddy_ddns
49

510

6-
godaddy_ddns.update_ip()
11+
@click.command()
12+
@click.option(
13+
'--config', '-c',
14+
default="/etc/godaddy-ddns/config.yaml",
15+
help="Path to configuration file (.yaml).",
16+
type=click.File('r')
17+
)
18+
@click.option('--force', '-f', is_flag=True, help="Update the IP regardless of the cached IP.")
19+
@click.option('--quiet', '-q', is_flag=True, help="Don't print to stdout.")
20+
@click.option('--nocolour', is_flag=True, help="Don't colourise messages to stdout.")
21+
def main(config, force, quiet, nocolour):
22+
# Define an echo function to account for 'quiet' and 'nocolour'
23+
def echo(msg):
24+
if quiet:
25+
pass
26+
elif nocolour:
27+
print(msg)
28+
else:
29+
godaddy_ddns.print_colourised(msg)
30+
31+
# Notify if forced
32+
if force:
33+
echo("Info: Beginning forced update.")
34+
35+
# Perform the update
36+
try:
37+
# Catch and record warnings to print later
38+
with warnings.catch_warnings(record=True) as caught_warnings:
39+
updated, myip, domains = godaddy_ddns.update_ip(config, force)
40+
except (godaddy_ddns.ConfigError,
41+
godaddy_ddns.BadResponse,
42+
PermissionError,
43+
ConnectionError) as e:
44+
# Echo the message and exit with failure
45+
echo(str(e))
46+
sys.exit(1)
47+
except:
48+
echo("Error: An unexpected exception occurred!")
49+
raise # raise the exception for debugging/issue reporting
50+
51+
# Print any warnings
52+
for warning in caught_warnings:
53+
echo(str(warning.message))
54+
55+
# Report the status of the update
56+
if updated:
57+
forced = "forcefully " if force else ""
58+
for domain in domains:
59+
echo("Success: IP address {}updated to {} for {}.".format(forced, myip, domain))
60+
else:
61+
echo("Success: IP address is already up-to-date ({})".format(myip))
62+
63+
64+
if __name__ == "__main__":
65+
main()

godaddy_ddns/__init__.py

Lines changed: 126 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import datetime
2-
import errno
32
import os
4-
import re
5-
import sys
3+
import warnings
64

75
import click
86
import pif
@@ -12,73 +10,75 @@
1210
from godaddypy.client import BadResponse
1311

1412

15-
@click.command()
16-
@click.option(
17-
'--config',
18-
default="/etc/godaddy-ddns/config.yaml",
19-
help="Path to configuration file (.yaml).",
20-
type=click.File('r')
21-
)
22-
@click.option('--force', is_flag=True, help="Update the IP regardless of the cached IP.")
23-
@click.option('--quiet', is_flag=True, help="Don't print to stdout.")
24-
def update_ip(config, force, quiet):
13+
class ConfigError(Exception):
14+
pass
15+
16+
17+
def update_ip(config_file, force):
18+
"""Update the IP address for the configured domains/subdomains
19+
20+
Parameters:
21+
- config_file: Open file or file-like object configuration file
22+
- force: boolean flag for forcing updates (True => force update)
23+
24+
Returns:
25+
- updated: bool indicating whether the IP address was updated
26+
- myip: str containing the current IP address
27+
- domains: list of updated domains (eg. ["[sub1,sub2].[example.com]"])
28+
"""
2529
# Load the configuration file
2630
try:
27-
conf = yaml.load(config)
28-
except yaml.MarkedYAMLError as e:
29-
# If the error is marked, give the details
30-
print("Error: Malformed configuration file.")
31-
print(e)
32-
sys.exit(errno.EINVAL)
33-
except yaml.YAMLError:
34-
# Otherwise just state that it's malformed
35-
print("Error: Malformed configuration file")
36-
sys.exit(errno.EINVAL)
31+
config = yaml.load(config_file)
32+
except (yaml.MarkedYAMLError, yaml.YAMLError) as e:
33+
raise ConfigError("Error: {}".format(e))
3734

3835
# Check the supplied log path
39-
if conf.get("log_path", False):
36+
log_path = config.get("log_path")
37+
if log_path:
4038
# Make sure that the log path exists and is writable
4139
try:
42-
touch(conf["log_path"])
40+
touch(log_path)
4341
except PermissionError:
44-
print("Error: Insufficient permissions to write log to '{}'.".format(conf['log_path']))
45-
sys.exit(errno.EACCES)
42+
msg = "Error: Insufficient permissions to write log to '{}'.".format(log_path)
43+
raise PermissionError(msg) # Currently no log, so just raise an exception
4644

4745
# Define the logging function
4846
def write_log(msg):
4947
now = datetime.datetime.now().isoformat(' ', timespec='seconds')
50-
with open(conf["log_path"], 'a') as f:
48+
with open(log_path, 'a') as f:
5149
f.write("[{now}]: {msg}\n".format(now=now, msg=msg))
52-
if not quiet:
53-
click.echo(msg)
5450
else:
5551
# No log file specified, so disable logging
5652
def write_log(msg):
57-
if not quiet:
58-
click.echo(msg) # Just print the message, don't log it
53+
pass
5954

6055
# Check the supplied cache path
61-
if conf.get("cache_path", False):
56+
cache_path = config.get("cache_path")
57+
if cache_path:
6258
# Make sure that the log path exists and is writable
6359
try:
64-
touch(conf["cache_path"]) # Create the file if necessary
60+
touch(cache_path) # Create the file if necessary
6561
except PermissionError:
66-
write_log("Error: Insufficient permissions to write to cache ({}).".format(conf['cache_path']))
67-
sys.exit(errno.EACCES)
62+
msg = "Error: Insufficient permissions to write to cache ({}).".format(cache_path)
63+
write_log(msg)
64+
raise PermissionError(msg)
6865

6966
# Define the caching functions
7067
def write_cache(ip_addr):
7168
now = datetime.datetime.now().isoformat(' ', timespec='seconds')
72-
with open(conf["cache_path"], 'w') as f:
69+
with open(cache_path, 'w') as f:
7370
f.write("[{}]: {}".format(now, ip_addr))
7471

7572
def read_cache():
76-
with open(conf["cache_path"], "r") as f:
73+
with open(cache_path, "r") as f:
7774
cached = f.readline()
7875
return (cached[1:20], cached[23:]) # date_time, ip_addr
7976
else:
8077
# No cache file specified, so disable caching and warn the user!
81-
write_log("Warning: No cache file specified, so the IP address will always be submitted as if new - this could be considered abusive!")
78+
msg = ("Warning: No cache file specified, so the IP address will always be submitted "
79+
"as if new - this could be considered abusive!")
80+
write_log(msg)
81+
warnings.warn(msg)
8282

8383
# Define the caching functions
8484
def write_cache(ip_addr):
@@ -88,11 +88,12 @@ def read_cache():
8888
return (None, None)
8989

9090
# Get IPv4 address
91-
myip = pif.get_public_ip("v4.ident.me")
91+
myip = pif.get_public_ip("v4.ident.me") # Enforce IPv4 (for now)
9292

9393
if not myip:
94-
write_log("Error: Failed to determine IPv4 address")
95-
sys.exit(errno.CONNREFUSED)
94+
msg = "Error: Failed to determine IPv4 address"
95+
write_log(msg)
96+
raise ConnectionError(msg)
9697

9798
# Check whether the current IP is equal to the cached IP address
9899
date_time, cached_ip = read_cache()
@@ -101,63 +102,125 @@ def read_cache():
101102
elif myip == cached_ip:
102103
# Already up-to-date, so log it and exit
103104
write_log("Success: IP address is already up-to-date ({})".format(myip))
104-
sys.exit(0)
105+
return (False, myip, None)
105106
else:
106107
write_log("Info: New IP address detected ({})".format(myip))
107108

108109
# Get API details
109-
account = Account(api_key=conf.get("api_key"), api_secret=conf.get("api_secret"))
110-
client = Client(account, api_base_url=conf.get("api_base_url", "https://api.godaddy.com"))
110+
api_key = config.get("api_key")
111+
api_secret = config.get("api_secret")
112+
113+
# Check that they have values
114+
missing_cred = []
115+
if not api_key:
116+
missing_cred.append("'api_key'")
117+
if not api_secret:
118+
missing_cred.append("'api_secret'")
119+
120+
if missing_cred:
121+
msg = "Error: Missing credentials - {} must be specified".format(" and ".join(missing_cred))
122+
write_log(msg)
123+
raise ConfigError(msg)
124+
125+
# Initialise the connection classes
126+
account = Account(api_key=config.get("api_key"), api_secret=config.get("api_secret"))
127+
client = Client(account, api_base_url=config.get("api_base_url", "https://api.godaddy.com"))
111128

112129
# Check that we have a connection and get the set of available domains
113130
try:
114131
available_domains = set(client.get_domains())
115132
except BadResponse as e:
116-
write_log("Error: Bad response from GoDaddy ({})".format(e._message))
117-
sys.exit(errno.CONNREFUSED)
133+
msg = "Error: Bad response from GoDaddy ({})".format(e._message)
134+
write_log(msg)
135+
raise BadResponse(msg)
118136

119137
# Make the API requests to update the IP address
120-
failed_domains = {} # Stores a set of failed domains - failures will be tolerated but logged
121-
for target in conf.get("targets", []):
138+
failed_domains = set() # Stores a set of failed domains - failures will be tolerated but logged
139+
succeeded_domains = []
140+
forced = "forcefully " if force else ""
141+
142+
for target in config.get("targets", []):
122143
try:
123144
target_domain = target["domain"]
124145
except KeyError:
125-
write_log("Error: Missing 'domain' in confuration file")
126-
sys.exit(errno.EINVAL)
146+
msg = "Error: Missing 'domain' for target in configuration file"
147+
write_log(msg)
148+
raise ConfigError(msg)
127149

128-
if type(target_domain) == str:
129-
target_domain = {target_domain}
150+
if isinstance(target_domain, str):
151+
target_domain = {target_domain} # set of one element
130152
else:
131-
target_domain = set(target_domain)
153+
target_domain = set(target_domain) # set of supplied targets
132154

133155
unknown_domains = target_domain - available_domains
134156
failed_domains.update(unknown_domains)
135157

136158
domains = list(target_domain & available_domains) # Remove unknown domains
159+
if not domains:
160+
continue # No known domains, so don't bother contacting GoDaddy
161+
137162
subdomains = target.get("alias", "@") # Default to no subdomain (GoDaddy uses "@" for this)
138163

139164
try:
140165
update_succeeded = client.update_ip(myip, domains=domains, subdomains=subdomains)
141166
except BadResponse as e:
142-
write_log("Error: Bad response from GoDaddy ({})".format(e._message))
143-
sys.exit(errno.CONNREFUSED)
167+
msg = "Error: Bad response from GoDaddy ({})".format(e._message)
168+
write_log(msg)
169+
raise BadResponse(msg)
144170

145171
if update_succeeded:
146-
write_log("Success: Updated IP for {subs}.{doms}".format(subs=subdomains, doms=domains))
172+
succeeded_domains.append("{subs}.{doms}".format(subs=subdomains, doms=domains))
173+
write_log("Success: IP address {}updated to {} for {}.".format(forced,
174+
myip,
175+
succeeded_domains[-1]))
147176
else:
148-
write_log("Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})".format(doms=target_domain, subs=subdomains))
149-
sys.exit(errno.CONNREFUSED)
177+
msg = "Error: Unknown failure for (domain(s): {doms}, alias(es): {subs})".format(
178+
doms=target_domain, subs=subdomains)
179+
write_log(msg)
180+
raise BadResponse(msg)
150181

151182
if failed_domains:
152-
write_log("Warning: The following domains were not found {}".format(failed_domains))
183+
msg = "Warning: The following domains were not found {}".format(failed_domains)
184+
write_log(msg)
185+
warnings.warn(msg)
153186

154-
# Write the new IP address to the cache and exit
187+
# Write the new IP address to the cache and return
155188
write_cache(myip)
156-
sys.exit(0)
189+
return (True, myip, succeeded_domains)
190+
191+
192+
def print_colourised(msg):
193+
"""Print messages automatically colourised based on initial keywords
194+
195+
Currently supports: "Success", "Info", "Warning", "Error", None
196+
"""
197+
if msg.startswith("Success"):
198+
style = {
199+
"fg": "green",
200+
"bold": True,
201+
}
202+
elif msg.startswith("Info"):
203+
style = {
204+
"fg": "blue",
205+
}
206+
elif msg.startswith("Warning"):
207+
style = {
208+
"fg": "yellow",
209+
}
210+
elif msg.startswith("Error"):
211+
style = {
212+
"fg": "red",
213+
"bold": True,
214+
}
215+
else:
216+
style = {} # Don't apply a special style
217+
218+
click.echo(click.style(msg, **style))
157219

158220

159-
# Define 'touch' function
160221
def touch(path):
222+
"""Touch a path, creating it if necessary
223+
"""
161224
# Ensure the path exists
162225
os.makedirs(os.path.dirname(path), exist_ok=True)
163226
# Create the file if necessary

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33

44
setup(name='godaddy-ddns',
5-
version='0.1',
5+
version='0.2',
66
description='DDNS-like update service for GoDaddy',
77
url='http://github.com/N-Parsons/godaddy-ddns',
88
author='Nathan Parsons',

0 commit comments

Comments
 (0)