mirror of https://github.com/craigerl/aprsd.git
Added new time plugins
This patch adds 2 new time plugins to allow admins to use their opencagedata APIkey or openweathermap API key to fetch the timezone from the lat/lon GPS coordinates for the callsign requesting the time. This will enable fetching the time local to the ham radio's last beacon, and not time local to the aprsd server instance running. If the location is not found, then the timezone will default to UTC. The 2 new plugins are - aprsd.plugins.time.TimeOpenCageDataPlugin Fetches timezone from lat/lon using the opencagedata api that can be found here: https://opencagedata.com/dashboard#api-keys This requires a new ~/.config/aprsd/aprsd.yml entry to specify the api key. opencagedata: apiKey: <the api key hash here> - aprsd.plugins.time.TimeOWMPlugin Fetches the timezone from lat/lon using the openweathermap api that can be found here: https://home.openweathermap.org/api_keys This requires a new ~/.config/aprsd/aprsd.yml entry to specify the api key. openweathermap: apiKey: <the api key hash here>
This commit is contained in:
parent
fdd5a6ba41
commit
fc3a747aa4
|
@ -10,9 +10,9 @@ RUN apk add --update git wget py3-pip py3-virtualenv bash fortune
|
||||||
|
|
||||||
# Setup Timezone
|
# Setup Timezone
|
||||||
ENV TZ=US/Eastern
|
ENV TZ=US/Eastern
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
#RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
RUN apt-get install -y tzdata
|
#RUN apt-get install -y tzdata
|
||||||
RUN dpkg-reconfigure --frontend noninteractive tzdata
|
#RUN dpkg-reconfigure --frontend noninteractive tzdata
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup --gid 1000 $APRS_USER
|
RUN addgroup --gid 1000 $APRS_USER
|
||||||
|
|
|
@ -2,11 +2,11 @@ FROM alpine:latest as aprsd
|
||||||
|
|
||||||
# Dockerfile for building a container during aprsd development.
|
# Dockerfile for building a container during aprsd development.
|
||||||
|
|
||||||
ENV VERSION=1.0.0
|
ENV VERSION=1.5.1
|
||||||
ENV APRS_USER=aprs
|
ENV APRS_USER=aprs
|
||||||
ENV HOME=/home/aprs
|
ENV HOME=/home/aprs
|
||||||
ENV APRSD=http://github.com/craigerl/aprsd.git
|
ENV APRSD=http://github.com/craigerl/aprsd.git
|
||||||
ENV APRSD_BRANCH="v1.1.0"
|
ENV APRSD_BRANCH="master"
|
||||||
ENV VIRTUAL_ENV=$HOME/.venv3
|
ENV VIRTUAL_ENV=$HOME/.venv3
|
||||||
|
|
||||||
ENV INSTALL=$HOME/install
|
ENV INSTALL=$HOME/install
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Utilities for plugins to use
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
LOG = logging.getLogger("APRSD")
|
||||||
|
|
||||||
|
|
||||||
|
def get_aprs_fi(api_key, callsign):
|
||||||
|
LOG.debug("Fetch aprs.fi location for '{}'".format(callsign))
|
||||||
|
try:
|
||||||
|
url = (
|
||||||
|
"http://api.aprs.fi/api/get?"
|
||||||
|
"&what=loc&apikey={}&format=json"
|
||||||
|
"&name={}".format(api_key, callsign)
|
||||||
|
)
|
||||||
|
response = requests.get(url)
|
||||||
|
except Exception:
|
||||||
|
raise Exception("Failed to get aprs.fi location")
|
||||||
|
else:
|
||||||
|
response.raise_for_status()
|
||||||
|
return json.loads(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_gov_for_gps(lat, lon):
|
||||||
|
LOG.debug("Fetch station at {}, {}".format(lat, lon))
|
||||||
|
try:
|
||||||
|
url2 = (
|
||||||
|
"https://forecast.weather.gov/MapClick.php?lat=%s"
|
||||||
|
"&lon=%s&FcstType=json" % (lat, lon)
|
||||||
|
)
|
||||||
|
LOG.debug("Fetching weather '{}'".format(url2))
|
||||||
|
response = requests.get(url2)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(e)
|
||||||
|
raise Exception("Failed to get weather")
|
||||||
|
else:
|
||||||
|
response.raise_for_status()
|
||||||
|
return json.loads(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_gov_metar(station):
|
||||||
|
LOG.debug("Fetch metar for station '{}'".format(station))
|
||||||
|
try:
|
||||||
|
url = "https://api.weather.gov/stations/{}/observations/latest".format(
|
||||||
|
station,
|
||||||
|
)
|
||||||
|
response = requests.get(url)
|
||||||
|
except Exception:
|
||||||
|
raise Exception("Failed to fetch metar")
|
||||||
|
else:
|
||||||
|
response.raise_for_status()
|
||||||
|
return json.loads(response)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_openweathermap(api_key, lat, lon, exclude=None):
|
||||||
|
LOG.debug("Fetch openweathermap for {}, {}".format(lat, lon))
|
||||||
|
if not exclude:
|
||||||
|
exclude = "minutely,hourly,daily,alerts"
|
||||||
|
try:
|
||||||
|
url = (
|
||||||
|
"https://api.openweathermap.org/data/2.5/onecall?"
|
||||||
|
"lat={}&lon={}&appid={}&exclude={}".format(lat, lon, api_key, exclude)
|
||||||
|
)
|
||||||
|
response = requests.get(url)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(e)
|
||||||
|
raise Exception("Failed to get weather")
|
||||||
|
else:
|
||||||
|
response.raise_for_status()
|
||||||
|
return json.loads(response.text)
|
|
@ -1,10 +1,8 @@
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from aprsd import plugin
|
from aprsd import plugin, plugin_utils, utils
|
||||||
import requests
|
|
||||||
|
|
||||||
LOG = logging.getLogger("APRSD")
|
LOG = logging.getLogger("APRSD")
|
||||||
|
|
||||||
|
@ -16,11 +14,15 @@ class LocationPlugin(plugin.APRSDPluginBase):
|
||||||
command_regex = "^[lL]"
|
command_regex = "^[lL]"
|
||||||
command_name = "location"
|
command_name = "location"
|
||||||
|
|
||||||
config_items = {"apikey": "aprs.fi api key here"}
|
|
||||||
|
|
||||||
def command(self, fromcall, message, ack):
|
def command(self, fromcall, message, ack):
|
||||||
LOG.info("Location Plugin")
|
LOG.info("Location Plugin")
|
||||||
# get last location of a callsign, get descriptive name from weather service
|
# get last location of a callsign, get descriptive name from weather service
|
||||||
|
try:
|
||||||
|
utils.check_config_option(self.config, "aprs.fi", "apiKey")
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Failed to find config aprs.fi:apikey {}".format(ex))
|
||||||
|
return "No aprs.fi apikey found"
|
||||||
|
|
||||||
api_key = self.config["aprs.fi"]["apiKey"]
|
api_key = self.config["aprs.fi"]["apiKey"]
|
||||||
try:
|
try:
|
||||||
# optional second argument is a callsign to search
|
# optional second argument is a callsign to search
|
||||||
|
@ -31,14 +33,13 @@ class LocationPlugin(plugin.APRSDPluginBase):
|
||||||
else:
|
else:
|
||||||
# if no second argument, search for calling station
|
# if no second argument, search for calling station
|
||||||
searchcall = fromcall
|
searchcall = fromcall
|
||||||
url = (
|
|
||||||
"http://api.aprs.fi/api/get?name="
|
try:
|
||||||
+ searchcall
|
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
|
||||||
+ "&what=loc&apikey={}&format=json".format(api_key)
|
except Exception as ex:
|
||||||
)
|
LOG.error("Failed to fetch aprs.fi '{}'".format(ex))
|
||||||
response = requests.get(url)
|
return "Failed to fetch aprs.fi location"
|
||||||
# aprs_data = json.loads(response.read())
|
|
||||||
aprs_data = json.loads(response.text)
|
|
||||||
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
|
LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
|
||||||
lat = aprs_data["entries"][0]["lat"]
|
lat = aprs_data["entries"][0]["lat"]
|
||||||
lon = aprs_data["entries"][0]["lng"]
|
lon = aprs_data["entries"][0]["lng"]
|
||||||
|
@ -53,15 +54,12 @@ class LocationPlugin(plugin.APRSDPluginBase):
|
||||||
# ) # unicode to ascii
|
# ) # unicode to ascii
|
||||||
delta_seconds = time.time() - int(aprs_lasttime_seconds)
|
delta_seconds = time.time() - int(aprs_lasttime_seconds)
|
||||||
delta_hours = delta_seconds / 60 / 60
|
delta_hours = delta_seconds / 60 / 60
|
||||||
url2 = (
|
|
||||||
"https://forecast.weather.gov/MapClick.php?lat="
|
try:
|
||||||
+ str(lat)
|
wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon)
|
||||||
+ "&lon="
|
except Exception as ex:
|
||||||
+ str(lon)
|
LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(ex))
|
||||||
+ "&FcstType=json"
|
wx_data["location"]["areaDescription"] = "Unkown Location"
|
||||||
)
|
|
||||||
response2 = requests.get(url2)
|
|
||||||
wx_data = json.loads(response2.text)
|
|
||||||
|
|
||||||
reply = "{}: {} {}' {},{} {}h ago".format(
|
reply = "{}: {} {}' {},{} {}h ago".format(
|
||||||
searchcall,
|
searchcall,
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from aprsd import fuzzyclock, plugin
|
from aprsd import fuzzyclock, plugin, plugin_utils, utils
|
||||||
|
from opencage.geocoder import OpenCageGeocode
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
LOG = logging.getLogger("APRSD")
|
LOG = logging.getLogger("APRSD")
|
||||||
|
@ -20,15 +21,7 @@ class TimePlugin(plugin.APRSDPluginBase):
|
||||||
def _get_utcnow(self):
|
def _get_utcnow(self):
|
||||||
return pytz.datetime.datetime.utcnow()
|
return pytz.datetime.datetime.utcnow()
|
||||||
|
|
||||||
def command(self, fromcall, message, ack):
|
def build_date_str(self, localzone):
|
||||||
LOG.info("TIME COMMAND")
|
|
||||||
# So we can mock this in unit tests
|
|
||||||
localzone = self._get_local_tz()
|
|
||||||
|
|
||||||
# This is inefficient for now, but this enables
|
|
||||||
# us to add the ability to provide time in the TZ
|
|
||||||
# of the caller, if we can get the TZ from their callsign location
|
|
||||||
# This also accounts for running aprsd in different timezones
|
|
||||||
utcnow = self._get_utcnow()
|
utcnow = self._get_utcnow()
|
||||||
gmt_t = pytz.utc.localize(utcnow)
|
gmt_t = pytz.utc.localize(utcnow)
|
||||||
local_t = gmt_t.astimezone(localzone)
|
local_t = gmt_t.astimezone(localzone)
|
||||||
|
@ -38,10 +31,94 @@ class TimePlugin(plugin.APRSDPluginBase):
|
||||||
local_min = local_t.strftime("%M")
|
local_min = local_t.strftime("%M")
|
||||||
cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1)
|
cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1)
|
||||||
|
|
||||||
reply = "{} ({}) ({})".format(
|
reply = "{} ({})".format(
|
||||||
cur_time,
|
cur_time,
|
||||||
local_short_str,
|
local_short_str,
|
||||||
message.rstrip(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
def command(self, fromcall, message, ack):
|
||||||
|
LOG.info("TIME COMMAND")
|
||||||
|
# So we can mock this in unit tests
|
||||||
|
localzone = self._get_local_tz()
|
||||||
|
return self.build_date_str(localzone)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeOpenCageDataPlugin(TimePlugin):
|
||||||
|
"""geocage based timezone fetching."""
|
||||||
|
|
||||||
|
version = "1.0"
|
||||||
|
command_regex = "^[tT]"
|
||||||
|
command_name = "Time"
|
||||||
|
|
||||||
|
def command(self, fromcall, message, ack):
|
||||||
|
api_key = self.config["aprs.fi"]["apiKey"]
|
||||||
|
try:
|
||||||
|
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Failed to fetch aprs.fi data {}".format(ex))
|
||||||
|
return "Failed to fetch location"
|
||||||
|
|
||||||
|
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
|
||||||
|
lat = aprs_data["entries"][0]["lat"]
|
||||||
|
lon = aprs_data["entries"][0]["lng"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
utils.check_config_option(self.config, "opencagedata", "apiKey")
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Failed to find config opencage:apiKey {}".format(ex))
|
||||||
|
return "No opencage apiKey found"
|
||||||
|
|
||||||
|
try:
|
||||||
|
opencage_key = self.config["opencagedata"]["apiKey"]
|
||||||
|
geocoder = OpenCageGeocode(opencage_key)
|
||||||
|
results = geocoder.reverse_geocode(lat, lon)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Couldn't fetch opencagedata api '{}'".format(ex))
|
||||||
|
# Default to UTC instead
|
||||||
|
localzone = pytz.timezone("UTC")
|
||||||
|
else:
|
||||||
|
tzone = results[0]["annotations"]["timezone"]["name"]
|
||||||
|
localzone = pytz.timezone(tzone)
|
||||||
|
|
||||||
|
return self.build_date_str(localzone)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeOWMPlugin(TimePlugin):
|
||||||
|
"""OpenWeatherMap based timezone fetching."""
|
||||||
|
|
||||||
|
version = "1.0"
|
||||||
|
command_regex = "^[tT]"
|
||||||
|
command_name = "Time"
|
||||||
|
|
||||||
|
def command(self, fromcall, message, ack):
|
||||||
|
api_key = self.config["aprs.fi"]["apiKey"]
|
||||||
|
try:
|
||||||
|
aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Failed to fetch aprs.fi data {}".format(ex))
|
||||||
|
return "Failed to fetch location"
|
||||||
|
|
||||||
|
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
|
||||||
|
lat = aprs_data["entries"][0]["lat"]
|
||||||
|
lon = aprs_data["entries"][0]["lng"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
utils.check_config_option(self.config, "openweathermap", "apiKey")
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Failed to find config openweathermap:apiKey {}".format(ex))
|
||||||
|
return "No openweathermap apiKey found"
|
||||||
|
|
||||||
|
api_key = self.config["openweathermap"]["apiKey"]
|
||||||
|
try:
|
||||||
|
results = plugin_utils.fetch_openweathermap(api_key, lat, lon)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.error("Couldn't fetch openweathermap api '{}'".format(ex))
|
||||||
|
# default to UTC
|
||||||
|
localzone = pytz.timezone("UTC")
|
||||||
|
else:
|
||||||
|
tzone = results["timezone"]
|
||||||
|
localzone = pytz.timezone(tzone)
|
||||||
|
|
||||||
|
return self.build_date_str(localzone)
|
||||||
|
|
|
@ -22,6 +22,8 @@ DEFAULT_CONFIG_DICT = {
|
||||||
"logfile": "/tmp/aprsd.log",
|
"logfile": "/tmp/aprsd.log",
|
||||||
},
|
},
|
||||||
"aprs.fi": {"apiKey": "set me"},
|
"aprs.fi": {"apiKey": "set me"},
|
||||||
|
"openweathermap": {"apiKey": "set me"},
|
||||||
|
"opencagedata": {"apiKey": "set me"},
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"aa": "5551239999@vtext.com",
|
"aa": "5551239999@vtext.com",
|
||||||
"cl": "craiglamparter@somedomain.org",
|
"cl": "craiglamparter@somedomain.org",
|
||||||
|
@ -111,6 +113,24 @@ def add_config_comments(raw_yaml):
|
||||||
end_idx,
|
end_idx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
end_idx = end_substr(raw_yaml, "opencagedata:")
|
||||||
|
if end_idx != -1:
|
||||||
|
# lets insert a comment
|
||||||
|
raw_yaml = insert_str(
|
||||||
|
raw_yaml,
|
||||||
|
"\n # Get the apiKey from your opencagedata account here: https://opencagedata.com/dashboard#api-keys",
|
||||||
|
end_idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
end_idx = end_substr(raw_yaml, "openweathermap:")
|
||||||
|
if end_idx != -1:
|
||||||
|
# lets insert a comment
|
||||||
|
raw_yaml = insert_str(
|
||||||
|
raw_yaml,
|
||||||
|
"\n # Get the apiKey from your openweathermap account here: https://home.openweathermap.org/api_keys",
|
||||||
|
end_idx,
|
||||||
|
)
|
||||||
|
|
||||||
return raw_yaml
|
return raw_yaml
|
||||||
|
|
||||||
|
|
||||||
|
@ -154,6 +174,35 @@ def get_config(config_file):
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
|
|
||||||
|
def check_config_option(config, section, name=None, default=None, default_fail=None):
|
||||||
|
if section in config:
|
||||||
|
|
||||||
|
if name and name not in config[section]:
|
||||||
|
if not default:
|
||||||
|
raise Exception(
|
||||||
|
"'{}' was not in '{}' section of config file".format(
|
||||||
|
name,
|
||||||
|
section,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
config[section][name] = default
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
default_fail
|
||||||
|
and name in config[section]
|
||||||
|
and config[section][name] == default_fail
|
||||||
|
):
|
||||||
|
# We have to fail and bail if the user hasn't edited
|
||||||
|
# this config option.
|
||||||
|
raise Exception(
|
||||||
|
"Config file needs to be edited from provided defaults.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception("'%s' section wasn't in config file" % section)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
# This method tries to parse the config yaml file
|
# This method tries to parse the config yaml file
|
||||||
# and consume the settings.
|
# and consume the settings.
|
||||||
# If the required params don't exist,
|
# If the required params don't exist,
|
||||||
|
@ -167,30 +216,12 @@ def parse_config(config_file):
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
def check_option(config, section, name=None, default=None, default_fail=None):
|
def check_option(config, section, name=None, default=None, default_fail=None):
|
||||||
if section in config:
|
try:
|
||||||
|
config = check_config_option(config, section, name, default, default_fail)
|
||||||
if name and name not in config[section]:
|
except Exception as ex:
|
||||||
if not default:
|
fail(repr(ex))
|
||||||
fail(
|
|
||||||
"'{}' was not in '{}' section of config file".format(
|
|
||||||
name,
|
|
||||||
section,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
config[section][name] = default
|
|
||||||
else:
|
|
||||||
if (
|
|
||||||
default_fail
|
|
||||||
and name in config[section]
|
|
||||||
and config[section][name] == default_fail
|
|
||||||
):
|
|
||||||
# We have to fail and bail if the user hasn't edited
|
|
||||||
# this config option.
|
|
||||||
fail("Config file needs to be edited from provided defaults.")
|
|
||||||
else:
|
else:
|
||||||
fail("'%s' section wasn't in config file" % section)
|
return config
|
||||||
return config
|
|
||||||
|
|
||||||
config = get_config(config_file)
|
config = get_config(config_file)
|
||||||
check_option(config, "shortcuts")
|
check_option(config, "shortcuts")
|
||||||
|
|
|
@ -23,4 +23,4 @@ if [ ! -e "$APRSD_CONFIG" ]; then
|
||||||
echo "'$APRSD_CONFIG' File does not exist. Creating."
|
echo "'$APRSD_CONFIG' File does not exist. Creating."
|
||||||
aprsd sample-config > $APRSD_CONFIG
|
aprsd sample-config > $APRSD_CONFIG
|
||||||
fi
|
fi
|
||||||
$VIRTUAL_ENV/bin/aprsd server -c $APRSD_CONFIG
|
$VIRTUAL_ENV/bin/aprsd server -c $APRSD_CONFIG --loglevel DEBUG
|
||||||
|
|
|
@ -11,3 +11,4 @@ aprslib
|
||||||
py3-validate-email
|
py3-validate-email
|
||||||
pre-commit
|
pre-commit
|
||||||
pytz
|
pytz
|
||||||
|
opencage
|
||||||
|
|
|
@ -2,14 +2,18 @@
|
||||||
# This file is autogenerated by pip-compile
|
# This file is autogenerated by pip-compile
|
||||||
# To update, run:
|
# To update, run:
|
||||||
#
|
#
|
||||||
# pip-compile requirements.in
|
# pip-compile
|
||||||
#
|
#
|
||||||
appdirs==1.4.4
|
appdirs==1.4.4
|
||||||
# via virtualenv
|
# via virtualenv
|
||||||
aprslib==0.6.47
|
aprslib==0.6.47
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
backoff==1.10.0
|
||||||
|
# via opencage
|
||||||
certifi==2020.12.5
|
certifi==2020.12.5
|
||||||
# via requests
|
# via requests
|
||||||
|
cffi==1.14.4
|
||||||
|
# via cryptography
|
||||||
cfgv==3.2.0
|
cfgv==3.2.0
|
||||||
# via pre-commit
|
# via pre-commit
|
||||||
chardet==4.0.0
|
chardet==4.0.0
|
||||||
|
@ -20,6 +24,8 @@ click==7.1.2
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# click-completion
|
# click-completion
|
||||||
|
cryptography==3.3.1
|
||||||
|
# via pyopenssl
|
||||||
distlib==0.3.1
|
distlib==0.3.1
|
||||||
# via virtualenv
|
# via virtualenv
|
||||||
dnspython==2.1.0
|
dnspython==2.1.0
|
||||||
|
@ -42,6 +48,8 @@ markupsafe==1.1.1
|
||||||
# via jinja2
|
# via jinja2
|
||||||
nodeenv==1.5.0
|
nodeenv==1.5.0
|
||||||
# via pre-commit
|
# via pre-commit
|
||||||
|
opencage==1.2.2
|
||||||
|
# via -r requirements.in
|
||||||
pbr==5.5.1
|
pbr==5.5.1
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
pluggy==0.13.1
|
pluggy==0.13.1
|
||||||
|
@ -50,6 +58,10 @@ pre-commit==2.9.3
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
py3-validate-email==0.2.12
|
py3-validate-email==0.2.12
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
pycparser==2.20
|
||||||
|
# via cffi
|
||||||
|
pyopenssl==20.0.1
|
||||||
|
# via opencage
|
||||||
pytz==2020.5
|
pytz==2020.5
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
pyyaml==5.3.1
|
pyyaml==5.3.1
|
||||||
|
@ -57,14 +69,19 @@ pyyaml==5.3.1
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# pre-commit
|
# pre-commit
|
||||||
requests==2.25.1
|
requests==2.25.1
|
||||||
# via -r requirements.in
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# opencage
|
||||||
shellingham==1.3.2
|
shellingham==1.3.2
|
||||||
# via click-completion
|
# via click-completion
|
||||||
six==1.15.0
|
six==1.15.0
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# click-completion
|
# click-completion
|
||||||
|
# cryptography
|
||||||
# imapclient
|
# imapclient
|
||||||
|
# opencage
|
||||||
|
# pyopenssl
|
||||||
# virtualenv
|
# virtualenv
|
||||||
thesmuggler==1.0.1
|
thesmuggler==1.0.1
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
|
|
@ -97,10 +97,9 @@ class TestPlugin(unittest.TestCase):
|
||||||
|
|
||||||
message = "time"
|
message = "time"
|
||||||
local_short_str = local_t.strftime("%H:%M %Z")
|
local_short_str = local_t.strftime("%H:%M %Z")
|
||||||
expected = "{} ({}) ({})".format(
|
expected = "{} ({})".format(
|
||||||
cur_time,
|
cur_time,
|
||||||
local_short_str,
|
local_short_str,
|
||||||
message.rstrip(),
|
|
||||||
)
|
)
|
||||||
actual = time.run(fromcall, message, ack)
|
actual = time.run(fromcall, message, ack)
|
||||||
self.assertEqual(expected, actual)
|
self.assertEqual(expected, actual)
|
||||||
|
|
Loading…
Reference in New Issue