不要把事情想得太简单,你可能只看到了九头蛇的一头。 [^1]: 结果可以在[这里](https://drive.google.com/file/d/1U5d5SiXLVkzDpS0i1dJIA4Hu5Qg704T9/view)查看。 [^2]: [Python Discussion 上的讨论](https://discuss.python.org/t/python-packaging-strategy-discussion-part-1/22420) [^3]: Pradyun Gedam: [Thoughts on the Python packaging ecosystem](https://pradyunsg.me/blog/2023/01/21/thoughts-on-python-packaging/) [^4]: Thea Flowers: [So You Want to Solve Python Packaging: A Practical Guide](https://twitter.com/theavalkyrie/status/1614842051580813318) [^5]: Pradyun Gedam: [How the Python Packaging community is organised](https://pradyunsg.me/blog/2023/01/14/python-packaging-organisation/) ## [PEP 582] 的最新修改 是的你没看错,PEP 582 并没有被遗忘,它的原作者 Kushal Das 又支楞起来了,在今年做了一些大的修改,基本上是重写了。相比 PDM 实现的 PEP 582 最初版本,一个最大的改变就是 目录结构变了,比如说一个包 `bottle`,它的导入路径从原来的: ``` __pypackages__/3.9/lib/bottle ``` 改成了: ``` __pypackages__/lib/python3.9/site-packages/bottle # Unix-like __pypackages__/Lib/site-packages/bottle # Windows ``` 这个路径模式(Install scheme),和现在已有的 Python 包安装路径是匹配的。好处在于,现在立即就可以用 `pip install --prefix __pypackages__` 来实现把包安装到 `__pypackages__` 中。 但坏处也有,注意到 Windows 的安装路径,是没有按 Python 版本号来区分的,这就意味着,如果你使用多个不同版本的 Python 安装到同一个位置,这些包是会互相覆盖的。实际使用中, 你根本无从得知这个包是来自哪个版本,造成了不小出问题的可能性。关于这一点,我也在 [Discussion](https://discuss.python.org/t/pep-582-python-local-packages-directory/963/378?u=frostming) 上提出来了,有 Core Developer 表示支持,作者也可能会采纳。但凡事有两面,如果在 Windows 路径中加入版本号,变成像 `__pypackages__/Lib/3.9/site-packages/bottle` 这样, 就和已有的路径模式不一致,需要增加新的,那么等到周边工具完全支持,周期一下就会漫长很多。但这是用短期的代价换取长期的好处,我觉得是值得的,等到后面出问题再想改就改不动了。 如果有读者用过 PDM 的 PEP 582 模式就会发现,无论如何改,都和 PDM 的实现不一样了[^6]。**我也计划在下个大更新中把路径改成最新标准**,提前预告一下。 除此之外,对于 PEP 582,还有以下几个争议点: - 如果脚本所在目录没找到 `__pypackages__`,是否去父级目录以及祖先目录去找?作者在提案中明确拒绝了,理由是安全的考量,因为用户可能执行 `/home/me/path/to/my/project/script.py`,却无意中加载了 `/home/me/__pypackages__`。 但说实话我没明白这个逻辑,如果恶意用户能在你 home 目录下拉屎,在安全这一块你就已经输了。而且严格控制只能加载当前目录的 `__pypackages__` 无疑降低了这个提案的价值,现在这个提案仅仅是能降低一下初学者的 上手难度,不用管虚拟环境那些,愿景未免有点太小了,况且 Node.js 也是会去父目录寻找 `node_modules` 的,大家快去说服作者。 - 当 `__pypackages__` 被加载时,是否要禁用系统的 site-packages?在我看来这是一个两难的选择,如果不禁用,确实会造成一些包版本冲突,但如果禁用了,那么就会造成一个结果,如果你在一个有 `__pypackages__` 的目录下时,安装在全局的命令行工具就用不了了。 关于这些我也在 [Discussion 上做了一个总结](https://discuss.python.org/t/pep-582-python-local-packages-directory/963/283?u=frostming),包括 PDM 是如何处理这些问题的。 [^6]: Pradyun Gedam: [PDM does not implement PEP 582, at the time of writing](https://pradyunsg.me/blog/2023/01/21/pdm-does-not-implement-pep-582/) ## 其他提案与动态 ### [PEP 704]: 安装 Python 包时明确要求虚拟环境 [pep 704]: https://peps.python.org/pep-0704/ 由于 PEP 582 存在的种种问题,PEP 704 应运而生,这是一个与 PEP 582 竞争的提案,它同样面向初学者,解决他们对包安装位置的困惑。它把安装包时,需要虚拟环境改成了默认行为,并且在没有激活虚拟环境时抛出错误。 ### [PEP 691]: 基于 JSON 的 Python 包索引的简单 API [pep 691]: https://peps.python.org/pep-0691/ [pep 503]: https://peps.python.org/pep-0503/ 之前的 Simple index([PEP 503]) 完全是一个 HTML 页面,下载包时工具要自行解析 HTML。这个提案建议支持 JSON 格式的响应,这样带来的好处有: 1. 解析 JSON 比解析 HTML 更容易 2. JSON 更方便表示结构化的数据,以后可以添加更多不同类型的字段 这个提案已经在 PyPI 上实现,安装器方面,pip 和 PDM(unearth) 也已经支持获取和解析 JSON 的响应。 ### `packaging 22.0` 去掉了解析非标准版本字符串的支持 [packaging]: https://github.com/pypa/packaging [pep 440]: https://peps.python.org/pep-0440/ [packaging] 自从 22.0 以后,不再支持解析非标准的版本字符串,并使用了自己实现的 Parser 替代了 `pyparsing` 依赖。所有不符合 [PEP 440] 规范的版本字符串都会导致解析失败并报错。 但 PyPI 上仍有大量的包,使用了过时的不合规范的版本号定义,这就导致使用 PDM 安装包时,可能会遇到 `InvalidRequirementError` 错误。举例来说: | 版本号或版本范围 | packaging\<22 | packaging>=22 | | ---------------- | :-----------: | :-----------: | | `1.0` | ✅ | ✅ | | `1.0a1` | ✅ | ✅ | | `1.0-rc1` | ✅ | ✅ | | `1.0-beta.1` | ✅ | ✅ | | `1.a.b` | ✅ | ❌ | | `>=1.0` | ✅ | ✅ | | `!=1.*` | ✅ | ✅ | | `>=1.0.*` | ✅ | ❌ | | `>=1.0.0+g1213` | ✅ | ❌ | 由于 pip 采用 vendor 策略,暂时没有升级新版本的 packaging,所以 pip 不会遇到问题。 --- ## 友好的 Python:面向对象接口 - **URL**: https://frostming.com/posts/2022/friendly-python-oop - **Date**: 2022-07-21 - **Tags**: friendly python, Python ### Content ## 前言 很久没更新了,写这篇文章是因为受了[高天直播 Code Review](https://www.bilibili.com/video/BV1pS4y1v7Ra?vd_source=cbdc2ec0024a8824a1cf7172ce10282f)的启发,深刻感觉到 Python 的灵活和强大,导致了实现同样的功能不同的人会写出完全不一样的代码。Python 语法糖有很多,如何把握「甜度」?过犹不及,我就本人的口味来细说一下。 **免责声明**,本文有关代码好坏的论断纯属个人喜好,总结的规律均为信口开河,若要争论个高下大可不必。 ## 写个配置类 小 F 是个后端程序员,他接到一个需求,写一个配置类作为项目配置的模型。很常见的需求嘛,先不管 Pydantic 之类的现成方案,就假设要造轮子好了。要怎么写呢?小 F 略加思索,写出来了: ```python @dataclasses.dataclass class Settings: db_user: str db_password: str db_host: str = 'localhost' db_port: int = 3306 ... # 省略一百个配置项 ``` 漂亮!用上了 `dataclass`,并提供了适当的默认值,小 F 还是很熟练嘛。 _小 F:算作者识相,没有故意安排我做反面教材。_ 这个类要怎么使用呢? ```python settinigs = Settings(db_user='root', db_password=os.getenv("DB_PASSWORD")) ``` ## 多种初始化方式 但一个配置类,哪能总是从参数去指定呢?一般这些东西,要么从环境变量读进去,要么从一个 JSON 或 YAML 文件读入,要么是某种配置管理系统。现在要怎么设计这个 API 呢? 小 F 马上明白,这是构造函数重载啊,不过等下,Python 不支持重载[^1],所幸 Python 非常动态和灵活,`__init__` 可以接受多种参数嘛[^2]: ```python class Settings: ... def __init__(self, **kwargs, from_env=False, from_file=None): if from_env: self._load_from_env() elif from_file: self._load_from_file(from_file) else: for k, v in kwargs.items(): setattr(self, k, v) ``` [^1]: 当然硬要用 `@singledispatch` 去做重载也不是不行,但你真的要这样? [^2]: 为了说明问题,暂时去掉了 `@dataclass`,用最朴素的 `__init__` 实现。 这种方法在我看来,有个最大的问题,就是传入它的参数**并不总是生效:**你传了 `from_env`,那 `from_file` 会被忽略,你传了 `from_file`,那其他的 `kwargs` 会被忽略,这对使用者是相当不友好的,他们必须看文档才知道这几个参数优先级是怎样的。 _小 F:你看你还是忍不住编排我了,我会这样写吗?然后他甩出来了另一个方案:_ ```python class Settings: ... def load_from_env() -> Settings: ... def load_from_file(filename: str) -> Settings: ... ``` 这个方案就修正了前述的问题:既然不同构造方法接受的参数不一致,那我专门暴露一个初始化函数就可以了。没错,这种方法也被广泛使用,比如 `json.load()` 和 `json.loads()`,不同的方法,接受不同的参数。但这种方法有一点*小小*的问题,要 import 的东西有点多,这里顶层就暴露了三个类和函数。小 F 的同事小 C 说,那这样可不可以: ```python class Settings: ... def load_from_env(self) -> None: ... def load_from_file(self, file: str) -> None: ... # 使用 settings = Settings() settings.load_from_env() ``` 我虽然在很多地方,包括之前公司的代码中看过这种写法,但我依然极其不推荐,原因是,如果 `Settings` 有一些**必填**参数,会在第一步实例化后得到一个**不完全初始化**的对象。正确的做法是不实例化,而直接改用 `@classmethod`: ```python class Settings def __init__(self, ...): ... @classmethod def from_env(cls) -> Settings: ... @classmethod def from_file(cls, file: str) -> Settings: ... # 使用 settings = Settings.from_env() ``` 这其实就是 Python 的构造方法重载,只是给构造方法起了不同的名字。这也是 `classmethod` 最主要的使用场景——使用某特殊方法构造一个实例,它们和 `__init__/__new__` 方法的地位是等同的。 一般实践上,我们会用 `__init__` 实现最 verbose(自定义空间最大)的构造方法,而用 `classmethod` 实现其他快捷的,可以从少数入参推断出全部参数的构造方法。而对于 `classmethod` 与普通函数的取舍,如果要构造的对象是整个包的主要导出对象(类似于 `yaml`, `json`),则可以用函数,否则如果这个对象是某个辅助对象,比如 `Connection`,`Config`,则适合用 `classmethod`,可以减少需要导入的成员。 ## 多配置选择 现在如果要为生产、测试创建不同的配置,覆盖某种值,要怎么做呢?小 F 又说,继承呗: ```python class ProductionSettings(Settings): ... class TestSettings(Settings): ... ``` 假如我需要按一个 key(Production/Testing) 来选择配置,该如何做呢?最朴素的方法,建立一个 mapping: ```python settings = {"production": ProductionSettings, "test": TestSettings} ``` IT JUST WORKS. 可是小 F 看不顺眼,觉得维护这个 mapping 很费劲。他看了一些 Python 的进阶书(不是说书不好),学会了一些**高端**用法,他三下五除二,就改成了下面这样: ```python class SettingsMeta(type): mapping = {} def __init__(cls, name, bases, attrs): super().__init__(name, bases, attrs) cls.mapping[re.match(r"(.+?)Settings$").group(1).lower()] = cls class Settings(metaclass=SettingsMeta): ... class ProductionSettings(Settings): ... # 使用 production_settings = SettingsMeta.mapping["production"] ``` 元类!斯~斯~斯国以!除了这需要一点时间才能看懂在做什么外,这么写有什么问题呢?有,这里有抽象泄漏的问题:`Settings` 的子类,保存到了元类 `SettingsMeta`上,而这个元类是创建 `Settings` 的「类工厂」,这里就形成了循环:`Settings -> SettingsMeta -> Settings`。使用者不应该感知到元类的存在,也就不应该调用他上面的属性。小 F 又说,这个 `mapping` 属性可以在 `Settings` 上使用啊,这没错,但这问题更大了,所有子类都有这个 `mapping`,也就是说,下面这种用法是可以的: ```python test_settings = ProductSettings.mapping["test"] ``` 这就完全不 make sense 了。同之前引入 `classmethod` 解决不完全初始化的对象一样,我们应该从根本上杜绝存在这种诡异代码的可能性。 我们千万要警惕这种「炫技」的倾向,如果有多种实现方案,一定要选择最直截了当简单明白的方法。另一个原则是,你提供的东西,最好只提供**刚好所需要**的接口,而不暴露多余的接口。`SettingsMeta` 元类就是一个反例,其实你只需要用一个 `mapping`,它自己倒是自动更新了,却导致了所有配置类多了一个 `mapping` 属性(即使你换成用一个方法获取,或是用 `__init_subclass__`,也会污染到它的子类,这是这种方法的最大问题)。 所以说,高端的食材,不是,高端的语言特性往往需要在适当时候使用。其实,用一个额外的字典来存映射没什么不好,如果不想手写 `mapping`,也可以用[注册机制](https://frostming.com/2021/07-07/friendly-python-1/#%E6%B3%A8%E5%86%8C%E4%B8%AD%E5%BF%83),能更干净的达到效果。 ## 减少重复(DRY) 本来打算结束,篇幅不够,那再加一个需求:让配置支持默认从环境变量中取值,并且更新环境变量能立刻生效。 这就需要每次读取值的时候都访问一次环境变量,这不就是 `@property` 的用武之地吗? ```python class Settings: @property def db_url(self): return self._db_url or os.getenv("CONFIG_DB_URL") @property def db_password(self): return self._db_password or os.getenv("CONFIG_DB_PASSWORD") ... ``` 这样一来,重复的代码就多起来了,要怎么 DRY 一下呢?这里又有不止一种方法了, 用 `__getattr__` 吗? ```python class Settings: def __getattr__(self, name): try: return os.environ["CONFIG_" + name.upper()] except KeyError: raise AttributeError(name) ``` 这种方法,违背了一条 Python 之禅,也就是我引用最多最有意义的一条: > Explicit is better than implicit. 你根本无法知道这个 `Settings` 到底支持多少个配置项,你只要设置 `CONFIG_FOO`,就能用 `settings.foo` 得到它的值,就算已经用了 `AttributeError` 防御不当使用,这威力也不必要地过大了。我推荐的方式,是用[描述符](https://docs.python.org/3/howto/descriptor.html): ```python class ConfigItem: def __set_name__(self, owner, name): self.name = name self.env_name = "CONFIG_" + name.upper() def __get__(self, instance, owner): if instance is None: return self return instance._data[self.name] or os.getenv(self.env_name) # 使用 class Settings: db_url = ConfigItem() db_password = ConfigItem() ... ``` 用描述符的最大好处,是他对补全很友好,而且可以加 type hint。后续如果要支持修改配置、值的校验、类型转换等,也很方便,比如接受一个校验函数: ```python class ConfigItem: def __init__(self, validate_func=None): if validate_func is None: validate_func = lambda self, x: x self.validate_func = validate_func def __set_name__(self, owner, name): self.name = name self.env_name = "CONFIG_" + name.upper() def __get__(self, instance, owner): if instance is None: return self value = instance._data[self.name] or os.getenv(self.env_name) return self.validate_func(instance, value) ``` 用起来更是相当酷炫: ```python class Settings: db_user = ConfigItem() @ConfigItem def db_password(self, value): if len(value) < 8: raise ValueError("Password must be at least 8 characters") return value ``` 再细看一眼,是不是特别像一个 ORM 了,接着扩展下去,这其实就是一个 ORM 或者类似 pydantic 的轮子的雏形。 --- ## PDM 2.0 有什么新特性? - **URL**: https://frostming.com/posts/2022/pdm-2 - **Date**: 2022-07-03 - **Tags**: Python, Packaging, PDM ### Content [The English Version](/2022/pdm-2-en/) [PDM] 在最近发布了 [2.0.0](https://github.com/pdm-project/pdm/releases/tag/2.0.0) 版本,新特性已基本完成。本文将介绍这次更新的内容。详细改动日志在[这里][changelog]可以看到。 ## 虚拟环境成为项目的默认配置 PDM 在建立之初,是标榜自己是一个支持 [PEP 582] 包结构的包管理器。但无奈,经过两年的观望,PEP 582 仍旧停留在 Draft 状态,并且迟迟没有进展。 它虽然在一开始令人眼前一亮并吸引了大批初始用户,但这也成为[PDM 被主流接纳的一个阻碍][gist comment],限制了它的推广。同时,虚拟环境在各种 IDE 和工具中有更好的支持。 现在我希望 PDM 并不仅是一个个人兴趣的项目,并且是一个支持 Python 打包最新规范的正经的包管理器,所以在 2.0 中,我们将虚拟环境成为项目的默认配置。 1. 在 `pdm init` 中,如果你没有选择一个已有虚拟环境中的解释器,PDM 会询问是否需要创建一个新的虚拟环境。如果创建,则会把这个虚拟环境作为项目环境, 否则,还是会启用 PEP 582 包结构。 2. 当你克隆一个已有的项目,在项目中第一次执行 `pdm install` 时,PDM 会检查项目中是否存在一个 `__pypackages__` 文件夹[^1],如果存在,会使用 PEP 582 包结构, 否则会**自动为你创建一个虚拟环境**并在其中安装依赖。 我们尽可能保证旧的项目不会变化,而只是新项目的默认方式变了。在文档中,PEP 582 也从首页最显眼的位置移动到了子页面中。所以 PDM 依然支持 PEP 582,只是不是默认的方式。 ## PDM 搭配其他后端 PDM 虽然有一个自己的后端[^2] [`pdm-pep517`][pdm-pep517] 但它其实没有和任何后端绑定,你依然可以使用比如 [`flit-core`][flit], [`hatchling`][hatch], [`setuptools`][setuptools] 作为后端,只要它支持读取 [PEP 621] 的元数据。甚至,你可以混用其他的包管理工具,例如,你完全可以用 [`flit`][flit] 来打包你的项目,而只把 PDM 作为依赖管理的工具来使用,因为前者不具备依赖管理的功能。 这就是拥抱标准所带来的巨大好处——工具只要遵循同一个规范,它们之间就可以共同存在,各自完成自己的工作。 ## 不再允许在项目依赖中包含 Editable 的包 原先,在 `pyproject.toml` 中的 `[project]:dependencies` 中你可以包含类似 `-e ./mypackage`, `-e git+https://github.com/psf/requests.git@main` 这样的 以 Editable 方式安装的包。但这是不符合 [PEP 631] 规范的,所以在 2.0 中,我们不再支持这种依赖,已有的 editable 包会弹出警告。 但别担心,你还是可以在 `[tool.pdm.dev-dependencies]` 中包含 Editable 的包,因为实际上它们只在开发中有用。 ## PDM 全局配置路径遵循 XDG 目录规范 原先 PDM 的全局配置是存在 `~/.pdm` 下面的,但在 2.0 中,它们将被放置在 `$CONFIG_HOME` 下面。具体的值为: - Linux: `$XDG_CONFIG_HOME/pdm` (一般为 `~/.config/pdm`) - MacOS: `~/Library/Preferences/pdm` - Windows: `%USERPROFILE%\AppData\Local\pdm` 你需要做一次性迁移(Linux 为例): ```bash $ mv ~/.pdm ~/.config/pdm ``` **感谢 [@noirbizarre] 的贡献。** ## 增加 [`pdm publish`][pdm publish] 命令 是的,这个功能是很多用户都希望拥有的,我们终于在 PDM 2.0 中加上了!直接执行 `pdm publish`,PDM 会自动打包项目,然后上传到 PyPI。当然,在这之前, 你需要配置好上传所需要的用户密钥。 ```bash $ pdm config repository.pypi.usernameGo ahead and bookmark this so you can link to it every few months when another baby faced, naive, precious little developer thinks that they can slay the hydra because they only see one head.
— Stargirl🌠 (@theavalkyrie) January 16, 2023
我本来就是随便发一个我的新发现,没想到影响挺大,为防止误导更多的人,我已经删除了原推。 --- ## Flask前后端分离实践:Todo App(2) - **URL**: https://frostming.com/posts/2018/09-27/flask-vue-todo2 - **Date**: 2018-09-27 - **Tags**: Python, Flask, Vue - **Description**: 表单与登录 ### Content > **前序文章** > > - [Flask 前后端分离实践:Todo App(1)](https://frostming.com/2018/09-18/flask-vue-todo1) > 使用 Vue.js 搭建 Todo App > > 本文项目地址: https://github.com/frostming/flask-vue-todo 在上一篇文章里我们已经用 Flask+Vue 搭建了一个可以把数据持久化到服务器的 Todo App。那么,为了让多人一起使用这个 App,我们需要对数据按用户做隔离,这样就自然需要一个注册/登录界面。在前后端分离的架构里,我们是怎么验证用户,保持会话的呢? ## 用户登录 先复习一下以往用 Flask 是怎么解决这问题的,没错,通过 Flask-Login 模块,从 request 中获取用户名和密码,验证通过后用`login_user`记录到会话中,之后的请求就会带有登录信息了。如果要退出登录,只需要调一下`logout_user`就可以了。 那么使用前后端分离以后,所有对后端的请求都是以 Ajax 的方式发送,上面的方法依然有效!区别仅仅在于,我们将请求改成 JSON 格式之后,后端是从`request.get_json()`中获取的。为此,我们专门建立一个名为`auth`的蓝图: ```python @bp.route('/login', methods=['POST']) def login(): user_data = request.get_json() form = LoginForm(data=user_data) if form.validate(): user = form.get_user() login_user(user, remember=form.remember.data) return jsonify({'status': 'success', 'user': user.to_json()}) return jsonify({'status': 'error', 'message': form.errors}), 403 ``` 后端只接收 POST 请求,因为 GET 都在前端那边,自然也就没有 login_view 的配置了。前端那边,axios 发请求时自动会带上 cookie,所以后端这边依然可以通过`flask_login.current_user`拿到当前用户。 ## 表单与验证 现在我们需要一个包含表单的登录页面,而我们知道,所有的页面都是前端渲染。所以这里 wtform 或 flask-boostrap 就不太能派上用场了。好在表单也比较简单,不是很难写。 ```html ``` 有一表单验证的工作,比如必填项,长度限制等,完全不需要后端的,可以在前端完成。我们需要写一个提交的函数,绑定到表单的 submit 动作上: ```javascript export default { methods: { checkForm(e) { e.preventDefault(); const vm = this; api .login({ username: this.username, password: this.password, remember: this.remember, }) .then((data) => { vm.$router.push({ path: "/" }, () => { vm.success("Logged in successfully!"); }); }) .catch((e) => { const errors = e.response.data.message; for (const key in errors) { errors[key].forEach((e) => vm.error(`${key}: ${e}`)); } }); }, }, }; ``` 但有些验证工作,比如密码校验,还是要麻烦后端的,所以这里我们获取后端返回的错误(储存在`data.message`中),然后依次渲染在页面中(这里我使用了一个 Vue 的插件[Vue-flask-message](https://www.npmjs.com/package/vue-flash-message)来完成)。 后端验证这一块,由于没有渲染需求了,可以不用 wtform 这一套,改用[marshmallow](https://github.com/marshmallow-code/marshmallow),但为了后面的方便,我还是使用了 Flask-WTF,把验证放到表单类里。 ```python from flask_wtf import FlaskForm class LoginForm(FlaskForm): username = StringField('Username', validators=[Length(max=64)]) password = PasswordField('Password', validators=[Length(8, 16)]) remember = BooleanField('Remember Me') def validate_username(self, field): if not self.get_user(): raise ValidationError('Invalid username!') def validate_password(self, field): if not self.get_user(): return if not self.get_user().check_password(field.data): raise ValidationError('Incorrect password!') def get_user(self): return User.query.filter_by(username=self.username.data).first() ``` 完成了登录部分,那么注册界面也大同小异,总结起来,大致思想是: - 对于无需后端的验证,由前端完成。 - 后端的验证,通过响应内容传回错误。 - 验证错误通过 Vue-flash-message 显示到页面上。 - login 和 register 的视图函数仅处理 POST 请求。 --- ## Flask前后端分离实践:Todo App(1) - **URL**: https://frostming.com/posts/2018/09-18/flask-vue-todo1 - **Date**: 2018-09-18 - **Tags**: Python, Flask, Vue - **Description**: 使用Vue.js搭建Todo App ### Content > **前言**:有句老话,叫做「现在都 8102 年了,你怎么还 XXX?」。随着前端工具的越来越完善和好用,现在前端能做的东西,实在太多了。而现在主流的 Flask 教程,都是基于以往的服务端模板渲染的架构。这在 2018 年,未免有些过时和笨拙。我曾看过一个用 Flask 写的 Todo 项目,每个交互都要向服务端发送 AJAX, 甚至连动态添加 DOM 元素都交由服务端渲染好再用 jQuery 添加。本系列文章,亦将由一个 Todo App 入手,实践前后端分离的架构,进而初窥全栈开发的门径。诚然,在前后端分离的系统中,Python 作为后端并不是一个最优的选择(出门右转 Golang)。但一则我热爱 Python 和 Flask,二则别的我也不太会,所以我假定阅读本文的作者,已经看过[Flask 的官方文档](http://flask.pocoo.org/docs/1.0/),或[Miguel Grinberg 的 Flask Mega 教程](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world)。那么现在开始。 > > 本文项目地址: https://github.com/frostming/flask-vue-todo ## 前后端分离的思路 有人要问,我为什么要前后端分离?这个说起就话长了,网上也能搜索到一些解答,不过可简要概括为以下两点: - 前端越来越重,很多页面交互,交由前端来实现会更加方便。我一直秉承:让专业的人做专业的事。这样事情会做得更漂亮。 - 前后端脱耦,可以分别交给两个人(团队)去做,且不会互相牵制。 那么哪些事是前端该做哪些是后端该做的呢?凡是涉及页面逻辑的部分,都是前端的工作,包括路由,渲染,页面事件等等。而只有在需要服务端的数据时,才给后端发请求。这样能大大节省网络带宽,减少网络延时的影响,一切交互都在本地,享受飞一般的感觉。特别是 Todo App,你肯定不想每加一项,勾选一个完成都要 busy 一阵吧,哪怕就是 10ms 也是无法忍受,所以 Todo App 非常适合用前后端分离来实现。当然,Todo App 也是各种前端框架的常见例子了,所以不太了解前端的各位 Pythonista 们,照着教程来一遍就差不多了,Flask 的后端仅仅需要完成两个功能: - 将内容持久化到服务器数据库 - 加入用户验证系统 ## 建立 Vue 应用 我选用 Vue.js 作为前端框架,当然用 React.js 也是可以的,它们都有强大的工具链,但 Vue.js 的好处是它是中国人开发的,几乎所有官方库文档都有中文版哦,方便学习嘛,而且个人感觉 Vue.js 用起来也确实更爽一点。 ### 目录结构 与传统的 Flask app 不同,前后端分离架构推荐静态文件(html, css, js 们)和 Python 文件分开存放。目录结构如下: ``` flask-vue-todo ├─frontend # 存放前端文件 ├─backend # 存放python文件 ``` ### 安装依赖 首先我们需要安装一键建立 Vue 项目的命令行工具`vue-cli`,安装方法(本文使用 Yarn 管理前端依赖,npm 大同小异): ```bash yarn global add vue-cli ``` 按照上述结构建立好项目之后,进入`frontend`目录,执行: ```bash vue init webpack-simple ``` 在一通眼花缭乱的进度条之后项目就建好了,执行`yarn run dev`看看效果吧。 ## 编写 Todo App 这一部分我不做重点介绍。此应用主要有以下逻辑: - 输入内容按下回车时在 Todo 列表中加上一项 - 点 Todo 项前的 checkbox 将其标为完成 - 点 Todo 项的红叉将其删除 - 通过 All, Undone, Completed 过滤显示的 Todo 项 我使用了[Vuex](https://vuex.vuejs.org/zh)来管理应用的状态。注意把 Ajax 请求部分单独抽离到一个文件中方便管理,这时你可以先让它永远返回成功即可。为了符合之后即将使用的 axios 的 API,可以这样写请求: ```javascript // api/index.js const mockTodos = [ { id: 1, text: "Item 1", done: false }, { id: 2, text: "Item 2", done: true }, ]; function mockRequest() { return new Promise((resolve, reject) => { setTimeout(() => { Math.random() < 0.85 ? resolve(mockTodos) : reject(new Error("Get Todo list error!")); }, 100); }); } const api = { getTodos() { return mockRequest("/todos"); }, }; ``` 当然,我在应用中做了很多美化的工作让应用显得高大上,符合 Vue.js 的 UI。 再次执行`yarn run dev`(若已执行则不必,它会自动热重载),你会看到编写完成的效果。`yarn run build`来编译已经写好的源文件。 ## 编写 Flask 部分 好了,现在切换到`backend`目录,后端的应用预备作为一个 API server 来使用,为方便与前端交互,输入输出均采用 JSON 格式,Flask 中可用`flask.jsonify`将结果转换成 JSON 的响应。告别看文档啃 Stackoverflow 爬坑,一切都是熟悉的味道,写起 Flask 来那还不上下翻飞?所有 API 请求都给它放到一个蓝图里,包含以下接口: - 获取所有 Todo 项,包括它们的完成状态 - 更新 Todo 项 - 删除 Todo 项 - 新建 Todo 项 这根本就是数据库的增删查改嘛,用上`flask-sqlalchemy`简直不要太方便。其实这么简单的操作无需用 SQL,用一个 NonSQL 数据库会更好,但为了部署 Heroku,它提供免费的 PostgreSQL 数据库。主路由就简单了,只剩一个`index`了,因为页面路由都交给前端了嘛,这时我们的 App 就成了一个「单页应用」(SPA)了。 ```python @app.route('/') def index(): return render_template('index.html') ``` 且慢,因为我们改换了目录结构,你必须告诉 Flask 静态文件和 html 文件的正确位置,编译好的静态文件在`frontend/dist`中,`index.html`在`frontend`中: ```python FRONTEND_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend') def create_app(): app = Flask( __name__, static_folder=os.path.join(FRONTEND_FOLDER, 'dist'), template_folder=FRONTEND_FOLDER ) ... ``` 对了,不要记得所有错误也都以 JSON 格式返回: ```python from werkzeug.exceptions import HTTPException @app.errorhandler(HTTPException) def handle_http_error(exc): return jsonify({'status': 'error', 'description': exc.description}), exc.code ``` 好了,现在可以把前端部分中之前伪造的请求换成真的了,我就用的 Vue.js 推荐的[axios](https://github.com/axios/axios),需要初始化一下,把所有请求变成 JSON 请求: ```javascript import axios from "axios"; const api = axios.create({ headers: { "Content-Type": "application/json", }, }); ``` 赶紧运行`FLASK_ENV=development flask run`吧,然后你就能从[http://localhost:5000](http://localhost:5000)看到效果了。 ## 关于前端开发服务器和后端开发服务器 可能有的同学已经注意到了,前端和后端都有一个开发服务器,但默认端口号不同,一个是 8080,一个是 5000。其中 8080 的开发服务器是调试前端页面用的,它仅仅包含静态文件,这时后端 API 是不可用状态的。但它有很多方便调试的功能,比如详尽的错误信息和热重载,编写前端时,用这个就够了,但 API 请求需要弄成假的。 而 5000 端口的服务器是 Flask 提供的,启用了`FLASK_ENV=development`可以打开 Flask 的`DEBUG`模式。它也能访问主页,但那是前端已经编译好的,不支持热重载哦。当然,Flask 支持 Python 文件热重载,现在知道专业的人干专业的事的道理了吧。区别总结如下: | | localhost:8080 | localhost:5000 | | ------------ | ------------------- | ------------------------------ | | 能访问页面? | 是 | 是 | | 能访问 API? | 否 | 是 | | 热重载 | HTML/CSS/Javascript | Python | | 更新静态文件 | 刷新生效 | 先`yarn run build`,再强制刷新 | 还有,这两个服务器,都不能在生产环境使用哦。那么,能否同时获取这两个服务器的好处呢?当然是可以了,同时启动两个服务器,然后把 Flask 启动的那个 5000 服务器单纯作为 API 服务器,从 8080 端口访问页面。这时,API 请求的 URL 就与当前地址不同了,需要显式配置请求 URL 到 5000 端口: ```javascript // ... const api = axios.create({ baseURL: "http://localhost:5000", headers: { "Content-Type": "application/json", }, }); ``` 好,到现在为止,我们已经成功运行了一个可以持久化到服务器数据库的 Todo App,下篇文章我们将会加入更多功能,使得 App 更加像样。 --- ## Python包管理工作流 - **URL**: https://frostming.com/posts/2018/09-14/python-packaging-flow - **Date**: 2018-09-14 - **Tags**: Python, Packaging - **Description**: 从pip到virtualenv到pipenv ### Content > 凡是一个成熟的软件生态都有它的软件源和对应的包管理工具,Python 也不例外,pip 就是它的(官方推荐的)包管理工具。可能很多小伙伴都对 pip 比较熟悉了,那么使用 pip 会有什么问题呢? ## 使用 requirements.txt 管理依赖 pip 最普通的使用方法就是`pip installTry my perf module, python3 -m perf timeit --duplicate=1024 -s 'a=1; b=2' ...
— Victor Stinner 🐍 (@VictorStinner) 2018年11月22日
* '"%s + %s = %s" % (a, b, a + b)': 254 ns +- 10 ns
* '"%d + %d = %d" % (a, b, a + b)': 268 ns +- 13 ns
* 'f"\{a} + \{b} = \{a + b}"': 273 ns +- 6 ns
* '"{} + {} = {}".format(a, b, a+b)': 321 ns +- 10 ns
七岁的那一年,抓住那只蝉,以为能抓住夏天 - 五月天《如烟》对于六月的好感,从小时候起,就根植在我的记忆里。 暑假就要来了,虽然已经告别学生时代多年,这个时节依然令我躁动。就像一周之中,最喜欢周五的晚上,它不同于周六的尽情狂欢,那是一种对于周末的未知与兴奋。六月,端午节可以放半天假到外婆家吃几枚粽子,中考可以放四天假,高考完可以去网吧包夜,期末考试完可以享受整整两个月的暑假。空气中弥漫的是西瓜的香,知了的聒躁,和七里香的旋律:「窗外的麻雀,在电线杆上多嘴,你说这一句,很有夏天的感觉」。 我的家乡是一个江西南部的小县城,端午节前后这段日子,老人称作「龙舟水」,雷鸣电闪,大雨瓢泼,路上积水是少不了的。于是我经常淌着浅至脚踝,深过膝盖的积水前行,伞是不太顶用的。与好友约好大战魔兽,涉水一路到网吧,辛酸又艰难,竟也有些快乐。 外面大汗淋漓,回到家全家都缩进空调屋里,边吃着饭,边回顾着《我爱我家》,那时候的电视剧,朴素又温馨,实足的包袱让我们不时发出阵阵笑声。 这三个片段平实无奇,却构成了我对六月,对夏天的独特记忆。 --- ## Python 列表小技巧 - **URL**: https://frostming.com/posts/2016/06-13/python-lie-biao-xiao-ji-qiao - **Date**: 2016-06-13 - **Tags**: Python, 雕虫小技 - **Description**: 关于列表的一些可能遇到的坑 ### Content > Python 中的列表和字典一样,都是可变数据类型,与字符串和整型相比,它具有一些独特的特性。在平常使用中, 也会经常遇到一些坑,本文试着举一些例子并说明。 ## 列表的拷贝 ### 直接赋值 ```python >>> a = [1,2,3] >>> b = a >>> a is b True >>> a[0]=5 >>> a [5, 2, 3] >>> b [5, 2, 3] ``` 在此例中,直接通过赋值将`a`赋给了`b`,此时,仅仅是为该列表增加了一个引用`b`,`a`与`b`指向内存中同一个区域,通过`a`改变列表的值也同时影响`b`。请注意,这里有一个坑,很多人在初始化语句中写`a = b = []`,这是错误的,会导致任意一个变动都会在`a`与`b`中同步,而且会很难 debug。正确写法应该是分别初始化。 ### 使用`list`工厂函数 为了创建一个`a`的拷贝,可以使用`list`工厂函数,这也是*Python Cookbook*中的推荐做法。 ```python >>> a = [1,2,3] >>> b = list(a) >>> a is b False >>> a[0]=5 >>> a [5, 2, 3] >>> b [1, 2, 3] ``` 完美,`a`和`b`是两个不同的列表了!除了使用工厂函数,切片也可以达到同样的效果: ```python >>> b = a[:] >>> b is a False ``` ### 使用`copy`模块 一切看起来都很美好,真的是这样吗? ```python >>> a = [1,[1,2],3] >>> b = list(a) >>> a[0] = 5 >>> a[1][1] = 5 >>> a [5, [1, 5], 3] >>> b [1, [1, 5], 3] ``` What?!`b`的第二个元素子列表中的值还是被改变了!原来,`list`和`[:]`都是在内存中创建了一个新的对象并赋给了`b`,但是子列表仍然只有一份。也就是说,只复制了「一层」。 为了解决这个问题,python 中自带了一个`copy`模块专门做拷贝的事情,使用模块下的`deepcopy`函数来深层次拷贝一个对象,调用它试试看: ```python >>> import copy >>> b = copy.deepcopy(a) >>> a[0] = 5 >>> a[1][1] = 5 >>> a [5, [1, 5], 3] >>> b [1, [1, 2], 3] ``` 妈妈再也不用担心我的列表交叉影响的问题了! ## 列表作为函数参数 ### 参数的默认值 python 的函数参数传递方法都是引用传递,而不是值传递,对于列表与字典这种可变类型就要特别小心了,可能会出现以下的错误: ```python >>> def foo(a=[]): ... a.append(1) ... print a ... >>> foo() [1] >>> foo() [1, 1] ``` `a`列表会保存上次调用之后的内容!因为这个列表在内存中创建以后就一直存在,参数`a`默认指向这个对象。所以,**要避免使用列表或字典作为函数的默认参数**。使用下面的方法代替,只多一行,而且非常 pythonic: ```python def foo(a=None, b=None): a = a or [] b = b or {} ... ``` ### 更改传入列表的内容。 由于列表是可变的,你可以在函数体内增删元素,更改元素的值,从而影响到原列表。 ```python >>> def foo(array): ... array.append(1) ... >>> a=[0] >>> foo(a) >>> a [0, 1] ``` 然而有些时候,我们希望整体更新列表,比如去重操作`array = list(set(array)`,这时用上面的方法就不行了,因为这里创建了一个新的列表`list(set(array))`并将其引用重新赋给了`array`,而函数内的局部变量`array`的更改是无法影响全局变量的,这与上一例不同的时上个例子并没有改变`array`的值,只是改变了`array`**指向的对象**的值。 这时候,我们又要搬出切片了。只需要改成`array[:] = list(set(array))`就可以了!因为切片本质上是对`array`中元素的操作,意思是把`list(set(array))`赋给`array`中的所有元素。 ```python >>> def unique(array): ... array[:]=list(set(array)) ... >>> a = [1, 2, 2, 3] >>> unique(a) >>> a [1, 2, 3] ``` --- ## Requests源码阅读v0.8.0 - **URL**: https://frostming.com/posts/2016/06-03/requestsyuan-ma-yue-du-v0-8-0 - **Date**: 2016-06-03 - **Tags**: Python, Requests, 源码阅读 ### Content > 工作两年了,一直用 python 写一些 API 之类的东西,自动化框架也有涉及,却一直感觉对个人技能提升缓慢。决定开这个坑,是之前看到[@wangshunping](https://github.com/wangshunping/read_requests)的**read requests**,生动有趣,可惜 0.8.0 之后没有更新了。待我稍稍有了一点看源码的动力,就想接着下去写。真是漫漫长路啊,4409 个 commit,1000 多个 PR,更何况还有珠玉在前,实在没有把握能把这块硬骨头给啃下来,写一点是一点吧。作为 python 的小学生,一些错误在所难免,希望大家指出,互相讨论。 > 下面就开始吧! ## 目标 ``` 0.8.0 (2011-11-13) ++++++++++++++++++ * Keep-alive support! * Complete removal of Urllib2 * Complete removal of Poster * Complete removal of CookieJars * New ConnectionError raising * Safe_mode for error catching * prefetch parameter for request methods * OPTION method * Async pool size throttling * File uploads send real names ``` ## 源码阅读 ### v0.7.1 ``` 0.7.1 (2011-10-23) ++++++++++++++++++ * Move away from urllib2 authentication handling. * Fully Remove AuthManager, AuthObject, &c. * New tuple-based auth system with handler callbacks. ``` - 移除`urllib2`的 authentication 处理 - 完全移除`AuthManager`, `AuthObject`和。。。&c? - 新的元组形式的`auth`机制和处理器回调函数。 #### 1. 移除`urllib2`的 authentication 处理 添加一个`auth.py`文件,加入了自己实现的 auth 处理器,包含`http_basic`和`http_digest`,分别对应 Headers 中`Autohorization`以`Basic`和`Digest`开头的情形。 #### 2. 完全删除`AuthManager`, `AuthObject`和。。。&c? 由于接口改用了 session,于是就没有必要使用`AuthManager`储存认证信息。使用自己实现的处理器,完全删除`models.py`中相关的代码。 #### 3. 新的元组形式的`auth`机制和处理器回调函数。 现在: ```python self.auth = auth_dispatch(auth) if self.auth: auth_func, auth_args = self.auth r = auth_func(self, *auth_args) self.__dict__.update(r.__dict__) ``` ```python def dispatch(t): """Given an auth tuple, return an expanded version.""" if not t: return t else: t = list(t) # Make sure they're passing in something. assert len(t) >= 2 # If only two items are passed in, assume HTTPBasic. if (len(t) == 2): t.insert(0, 'basic') # Allow built-in string referenced auths. if isinstance(t[0], basestring): if t[0] in ('basic', 'forced_basic'): t[0] = http_basic elif t[0] in ('digest',): t[0] = http_digest # Return a custom callable. return (t[0], tuple(t[1:])) ``` 通过`dispatch`函数,若传入二元元组,则默认前面加上`'basic'`,使用`http_basic`处理,否则需要指定处理类型。支持自定义处理器: ```python def pizza_auth(r, username): """Attaches HTTP Pizza Authentication to the given Request object. """ r.headers['X-Pizza'] = username return r Then, we can make a request using our Pizza Auth:: >>> requests.get('http://pizzabin.org/admin', auth=(pizza_auth, 'kenneth'))