golang写的命令行天气预报wego,其github上居然有几千个star,于是就想着用python来写写看。
写完后发现还挺有意思的。
基本思路
调用forecast.io的api,根据其返回的数据处理后显示在终端。
golang
因为wego是golang写的,花了一天时间专门了解golang,足够看懂wego代码了。
后端、前端可切换
Template Method模式.
不管是什么backend,只要实现fetch方法。
不管是什么frontend,只要实现render方法。
class Backend(object):
def fetch(self, arg_ns):
raise NotImplementedError
class Frontend(object):
def render(self, data, unit):
raise NotImplementedError
...
def main():
arg_ns = get_arg_namespace()
be = ALL_BACKENDS[arg_ns.backend]
r = be.fetch(arg_ns)
fe = ALL_FRONTENDS[arg_ns.frontend]
fe.render(r, unit=arg_ns.unit)
network
发请求调用api毫无疑问用requests, 当然,也可以用标准库urllib。
r = requests.get(url)
if r.status_code == 200:
r.json()
requests的接口很优雅。
命令行解析
标准库argparse实在是太强大了,我完全没有考虑用docopt的想法。
parser = argparse.ArgumentParser(description='weatpy for weather forecast')
parser.set_defaults(**defaults)
parser.add_argument('location', help='LOCATION to be queried (default "22.5333,114.1333")')
parser.add_argument('-v', action='count')
parser.add_argument('-b', '--backend', help='BACKEND to be used. (default "forecast.io")')
parser.add_argument('-f', '--frontend', help='FRONTEND to be used. (default "ascii-art-table")')
parser.add_argument('-d', '--numdays', type=int, help='NUMBER of days of weather forecast to be displayed (default 3)')
parser.add_argument('-u', '--unit', help='UNITSYSTEM to use for output. Choices are: metric, imperial, si (default "metric")')
parser.add_argument('--api-key', help='the api KEY to use')
parser.add_argument('--lang', help='LANGUAGE to request from forecast.io (default zh)')
arg_namespace = parser.parse_args()
使用ssh,应该有使用过ssh -vvv address, 即根据不同的v参数个数,打出不同级别的log。可以-v或-vv或-vvv, 怎么用argparse实现这个功能呢?
argparse有action='count'
这个action,可以进行计数。
怎么实现优先读取命令行参数,再读取配置文件~/.weatpyrc呢?
思路是解析文件配置后传到parser去 parser.set_defaults(**defaults)
这里遇到的一个问题是python的标准库ConfigParser是严格要求配置文件要有section的,否则会报MissingSectionHeaderError错误。
而很多unix风格的配置文件是没有section的。于是这里做了一下处理。
class FakeGlobalSectionHead(object):
def __init__(self, fp):
self.fp = fp
self.sechead = '[global]\n'
def readline(self):
if self.sechead:
try:
return self.sechead
finally:
self.sechead = None
else:
return self.fp.readline()
...
cp = ConfigParser.ConfigParser()
cp.readfp(FakeGlobalSectionHead(open(CONFIG_FILE)))
defaults = dict(cp.items('global'))
...
parser.set_defaults(**defaults)
数据结构
python的namedtuple看起来有点像golang的struct,然而namedtuple的实例是只读的。基本就只是给元组加了个名字而已,功能很弱。
于是用一个自定义的DataObject对象来放复杂数据,用__slots__来限制属性。
字符画前端
要把一天中早上、中午、傍晚、深夜4个阶段的天气预报给拼成一个表格。
一不小心,一言不合线就乱了,就对不齐了。
对于纯ascii字符,python自带的字符串格式化或者.format()函数是可以解决问题了。然而有中文就对不齐了,还有°C之类的字符,还要考虑颜色字符如’\033[0;32mhello\033[0m’。
处理代码在AatFrontend.aatpad()。
在这里需要明确的一点是,“字节串长度跟字符串长度是2个不同的概念;不同的字符在终端显示的宽度不一定相同”。
比如字符’a’和’雨’,
- ‘a’用1个字节,’雨’在utf-8下用3个字节
- ‘a’是1个字符,’雨’是一个字符
- 在我的iterm2终端下,2个’a’占用的宽度才顶一个’雨’
处理的思路大概是这样,天气的图标是事先画好的,宽度都一样。只需要考虑图标右边的信息。因为信息是左对齐的,所以,只需要算出右边需要填充多少空格就行了。此消彼长,中文字符占用多的空间,相应的右边就少填充些空格。
最后,不管是英文版还是中文版,都可以正常显示表格,对齐虚线。
终端是怎么显示颜色的?
json前端
怎么把一个嵌套的python对象给转成json呢?
自定义JSONEncoder,重写default()方法。
class ComplexEncoder(json.JSONEncoder):
DATE_FORMAT = "%Y-%m-%d"
TIME_FORMAT = "%H:%M:%S"
def default(self, obj):
if hasattr(obj, 'to_json'):
return obj.to_json()
elif isinstance(obj, datetime.datetime):
return obj.strftime("%s %s" % (self.DATE_FORMAT, self.TIME_FORMAT))
else:
return json.JSONEncoder.default(self, obj)
class JSONFrontend(iface.Frontend):
def render(self, data, unit):
print json.dumps(data, cls=ComplexEncoder, indent=4, ensure_ascii=False)
当然,还需要类提供to_json()方法。 这里放在父类DataObject里,其它子类都自动继承了这个方法,不用每个子类都写一遍。
class DataObject(object):
...
def to_json(self):
return {attr: getattr(self, attr) for attr in self.__slots__}
编写setup.py
怎么实现安装后可以直接在终端执行weatpy而不用python weatpy.py呢?
在setup.py里面指定
setup(
...
entry_points={
'console_scripts': ['weatpy = weatpy.main:main']
},
...
)
怎么在安装的时候自动创建~/.weatpyrc配置文件呢?
在setup.py中可以写自定义脚本,当然也可以用来创建文件了。
from distutils.command.build_py import build_py
class my_build_py(build_py):
def run(self):
# honor the --dry-run flag
if not self.dry_run:
if not os.path.exists(CONFIG_FILE):
print 'creating %s' % CONFIG_FILE
with open(CONFIG_FILE, 'w') as f:
f.write(...)
# distutils uses old-style classes, so no super()
build_py.run(self)
setup(
...
cmdclass={'build_py': my_build_py},
)