Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements #3

Open
pyx opened this issue Oct 21, 2016 · 14 comments
Open

Improvements #3

pyx opened this issue Oct 21, 2016 · 14 comments

Comments

@pyx
Copy link

pyx commented Oct 21, 2016

好久不見,決定做輪子啦。
我剛剛看了一下現在的 code base,覺得好多地方可以更好,包括風格,架構,減少重複代碼,更 pythonic 等,有時間我可以一個個 pull request 來。
在此之前,我決定將上次弄的半成品(我發過截圖那個)放出來,或許你可以參考一下。
純 python 和 hy 的 port 都放。
i18n 和 log 可以很簡單寫好 production-ready 的代碼,大概幾十行內就行,但我不確定什麼時候有時間準備 pull request,
另外一些設計決定,如果你有興趣的話我們可以再深入討論一下。

@pyx
Copy link
Author

pyx commented Oct 21, 2016

臨時整理了一下,python 的放出來了
https://github.com/pyx/arsenal
這個 demo 的 logging 和 i18n 當時沒打算弄,而完整的不方便公開,我有時間另外發個 pull request 吧

@910JQK
Copy link
Owner

910JQK commented Oct 21, 2016

8pm 大師~
不得不承認我的 code 確實不大行,有些地方寫的時候也意識到了,但不知道怎麽改。前輩能蒞臨指導,那真是再好不過啦。
至於設計決定,我明天寫個 README 好了(雖然有所規劃,在貼吧我也沒說很多)。

@pyx
Copy link
Author

pyx commented Oct 21, 2016

我覺得首先是目標吧,你應該有腹稿的了,確實有必要寫出來,但不一定在 README,比如

總體原則:
KISS,即貼吧 circa 2006-2010

計劃要完成的 features:

  • user auth & auth
  • moderation
  • sticky post
  • topic and sub-topic
  • support source code posting/highlighting
  • @ notification
  • private message

可能有用的 features:

  • user voting on content (reddit, hackernews, stackoverflow)
  • view ordered by votes

deployment & hosting 方案:

  • VPS self-hosting
  • cloud service

coding 策略與 coding style:

  • minimal dependencies vs third-party libs
  • testing strategy
  • code quality control

這些先大概定下來會好很多,而且基本上都影響 lib 的選擇和 coding 方式

README 主要是簡單說 what, how 就行了
另外可考慮選個 license,沒有等於 all right reserved 的。

下面分開說一下細節,想到哪寫到哪

@pyx
Copy link
Author

pyx commented Oct 21, 2016

首先說架構吧

考慮儘可能自動化,比如新開發者怎麼配置好環境等

至少要準備 requirements.txt and/or setup.py,requirements 甚至可以細分,比如 end user 的和開發者的,開發者的應該多了 testing 工具的依賴之類

初始數據庫等也應早考慮寫成腳本

我將那個半成品放出來目的就是覺得應該對你有參考價值,用 make, fabric, flask-script 等自動化這些過程,其中 flask-script 目前有可能被 flask 0.11 開始的功能取代,比如 flask 這個 command 和 click 寫 cli。

目的就是新用戶/開發者應該能:

git clone https://github.com/910JQK/linuxbar/ REPOS
cd REPOS
# mkvirtualenv etc.
pip install -r requirements.txt
$EDITOR some-config-py
./manage.py init_db  # etc.
./manage.py run

馬上就能用之類的

@pyx
Copy link
Author

pyx commented Oct 21, 2016

然後是一些 code 的問題吧,我一個個文件按順序來看

app.py:


import

linuxbar/app.py

Lines 4 to 5 in 1b92a23

from flask import Flask, Response, request, session, redirect, url_for, \
render_template, send_file, abort

我個人覺得 \ 不好看也容易出錯,可以用括號,這樣怎麼分行都方便,如:

from flask import (Flask, Response, request, session, redirect, url_for,
                   render_template, send_file, abort)

flask app

linuxbar/app.py

Lines 7 to 8 in 1b92a23

app = Flask(__name__)
babel = Babel(app)

建議使用 app factory,方便以後測試和 deploy
見:http://flask.pocoo.org/docs/0.11/patterns/appfactories/


local config

linuxbar/app.py

Lines 26 to 39 in 1b92a23

# Enable debug mode for test
DEBUG = True
EMAIL_ADDRESS = 'no_reply@foo.bar'
# Codepoints, must be greater than 3
SUMMARY_LENGTH = 60
# Fixed for positioning
COUNT_SUBPOST = 10
# String because input is string
BAN_DAYS_LIST = ['1', '3', '10', '30']
IMAGE_FORMATS = ['png', 'gif', 'jpeg']
IMAGE_MIME = {'png': 'image/png', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}
UPLOAD_FOLDER = 'upload'
MAX_UPLOAD_LENGTH = 5 * 1024 * 1024

config 最好單獨一個 module,比如
config.py 或 dev_config.py 或 default_config.py 之類的,依賴關係和 overwrite 可以用 import 解決,另外還有 class-based 的,用 class 當 namespace 使用,不過我個人看法覺得是 abuse 了,雖然寫起來不錯。
可以參考我那個的 config 處理和這裡:
http://flask.pocoo.org/docs/0.11/config/#configuration-best-practices


def _

linuxbar/app.py

Lines 49 to 51 in 1b92a23

# reserved for l10n
def _(string):
return string

每個文件裡的 _,其實可以放在一個工具 module 裡面,叫 utils.py 或者 common.py 或者 tools.py 什麼的
每個文件裡面就可以改成

from .utils import _

等 i18n 設好以後,將這一行的 .utils 改成 i18n 的就行

另外,配置好 i18n 也是很簡單的,提取/更新 .po 文件也可以用 make 或 fabric 或 manage.py 自動起來


def send_mail

linuxbar/app.py

Lines 54 to 76 in 1b92a23

def send_mail(subject, addr_from, addr_to, content, html_content=''):
'''Send an HTML email
@param str subject
@param str addr_from
@param str addr_to
@param str content
@return void
'''
if(html_content):
msg = MIMEMultipart('alternative')
msg_plaintext = MIMEText(content, 'plain')
msg_html = MIMEText(html_content, 'html')
msg.attach(msg_html)
msg.attach(msg_plaintext)
else:
msg = MIMEText(content, 'plain')
msg['Subject'] = subject
msg['From'] = addr_from
msg['To'] = addr_to
smtp = smtplib.SMTP('localhost')
smtp.send_message(msg)
smtp.quit()

Flask-Mail 可以簡化這個
https://pypi.python.org/pypi/Flask-Mail
最主要還可以支持測試,即 TESTING 時不寄出,並可以存入一個 outbox 變量方便檢測內容


def check_permission

linuxbar/app.py

Lines 79 to 102 in 1b92a23

def check_permission(operator, board, level0=False):
'''Check if `operator` is administrator of `board` ('' means global)
@param int operator
@param str board
@return bool
'''
check_global = forum.admin_check(operator)
if(check_global[0] != 0):
raise ForumPermissionCheckError(check_global)
if(not board):
return check_global[2]['admin']
else:
check = forum.admin_check(operator, board)
if(check[0] != 0):
raise ForumPermissionCheckError(check)
if(not check[2]['admin']):
board_admin = False
else:
if(level0):
board_admin = (check[2]['level'] == 0)
else:
board_admin = True
return (check_global[2]['admin'] or board_admin)

首先我認為這個應該屬於 auth 或 user 或 utils 甚至 db (其實我認為 models 可能更合適) 的 module 里,app.py 應該只有關於 app 創建等的內容

然後,如果這個函數都在 view function 里用,可以直接 abort(401),401/404 的界面可以做得很 fancy 的。


def json_response

linuxbar/app.py

Lines 105 to 114 in 1b92a23

def json_response(result):
'''Generate JSON responses from the return values of functions of forum.py
@param tuple result (int, str[, dict])
@return Response
'''
formatted_result = {'code': result[0], 'msg': result[1]}
if(len(result) == 3 and result[2]):
formatted_result['data'] = result[2]
return Response(json.dumps(formatted_result), mimetype='application/json')

http://flask.pocoo.org/docs/0.11/api/#flask.json.jsonify
如果要提供 REST API,還有兩三個 extension 可選


error message handling

linuxbar/app.py

Lines 117 to 147 in 1b92a23

def err_response(result):
'''Generate responses for general errors (result[0] != 0)
@param tuple result (int, str[, dict])
@return Response
'''
return render_template('error.html', result=result)
def validation_err_response(err, json=True):
'''Generate responses for validation errors
@param ValidationError err
@return Response
'''
if(json):
return json_response((255, _('Validation error: %s') % str(err)) )
else:
return render_template(
'error.html',
result = (255, _('Validation error: %s') % str(err))
)
def permission_err_response(err):
'''Generate responses for permission check errors
@param ForumPermissionCheckError err
@return Response
'''
return json_response(err.args[0])

還沒看到怎麼用的,直覺感覺沒必要,one-liner


made config public to templates

linuxbar/app.py

Lines 150 to 152 in 1b92a23

@app.context_processor
def inject_data():
return dict(config=config)

還沒看到其他文件,不過一般其他 module, template 知道的越少越好,不會有意外的依賴,so,先看下去


smart date repr.

linuxbar/app.py

Lines 155 to 202 in 1b92a23

@app.template_filter('date')
def format_date(timestamp, detailed=False):
# behaviour of this function must be consistent with the front-end
if(detailed):
return datetime.datetime.fromtimestamp(int(timestamp)).isoformat(' ');
date = datetime.datetime.fromtimestamp(timestamp)
delta = round((datetime.datetime.now() - date).total_seconds())
if(delta < 60):
return _('just now')
elif(delta < 3600):
minutes = delta // 60
if(minutes == 1):
return _('a minute ago')
else:
return _('%d minutes ago') % minutes
elif(delta < 86400):
hours = delta // 3600
if(hours == 1):
return _('an hour ago')
else:
return _('%d hours ago') % hours
# 604800 = 86400*7
elif(delta < 604800):
days = delta // 86400
if(days == 1):
return _('a day ago')
else:
return _('%d days ago') % days
# 2629746 = 86400*(31+28+97/400+31+30+31+30+31+31+30+31+30+31)/12
elif(delta < 2629746):
weeks = delta // 604800
if(weeks == 1):
return _('a week ago')
else:
return _('%d weeks ago') % weeks
# 31556952 = 86400*(365+97/400)
elif(delta < 31556952):
months = delta // 2629746
if(months == 1):
return _('a month ago')
else:
return _('%d months ago') % months
else:
years = delta // 31556952
if(years == 1):
return _('a year ago')
else:
return _('%d years ago') % years

第一反映是這個不需要自己寫的,比如 dev 版本的 babel 有:

flask_babel.format_timedelta

然後是這個應該放在 utils.py 之類的
再然後是,我個人不太喜歡後台 render 這個,主要原因有二,
一 用戶 timezone 不好處理,用戶自設嗎,還是 geoip,只為 eye-candy 都太 heavy 了
二 後台 render 的,存下 html 以後會不準確

所以如果我要使用 smart date 的,一般用 front-end 手段,如 Moment.js,有 extension 方便使用:
https://pypi.python.org/pypi/Flask-Moment


view index

linuxbar/app.py

Lines 205 to 207 in 1b92a23

@app.route('/')
def index():
return board('')

either redirect or:

@app.route('/')
@app.route('/board/<name>')
def board(name=''):
    ....

view board

linuxbar/app.py

Lines 210 to 242 in 1b92a23

@app.route('/board/<name>')
def board(name):
pn = request.args.get('pn', '1')
items_per_page = int(config['count_topic'])
try:
validate_id(_('Page Number'), pn)
except ValidationError as err:
return validation_err_response(err, json=False)
if(int(pn) != 1 or name == ''):
pinned_topics = []
else:
result_pinned = forum.topic_list(name, 1, forum.PINNED_TOPIC_MAX, pinned=True)
if(result_pinned[0] != 0):
return err_response(result_pinned)
else:
pinned_topics = result_pinned[2]['list']
result = forum.topic_list(name, int(pn), items_per_page)
if(result[0] != 0):
return err_response(result)
elif(len(result[2]['list']) == 0 and pn != '1'):
return err_response((248, _('No such page.')) )
else:
result[2]['list'] = pinned_topics + result[2]['list']
return render_template(
'topic_list.html',
index = (not name),
board = name,
data = result[2],
pn = int(pn),
items_per_page = items_per_page
)

看到這裡,我發現我犯了個錯誤,光這個文件就 1300+ loc,看來我不能這樣搞 :(

轉下一貼

@pyx
Copy link
Author

pyx commented Oct 21, 2016

現在開始就粗一點說 鼠標亂飄 :) 看到什麼說什麼


自己寫 pagination,其實 peewee 有 flask extension,http://pypi.python.org/pypi/Flask-Peewee
提供了 pagination 還有其他功能的,但已經是維護模式了。
這就帶來另外一個問題了,選 peewee 的理由,我個人 prefer SQLAlchemy,雖然配置麻煩點,但上限高,免得以後換,Flask-SQLAlchemy http://pypi.python.org/pypi/Flask-SQLAlchemy 也提供整合和 pagination 等,而且是 Armin 自己維護。


應該盡量避免用 magic number,特別是兩層或以上的 collection 嵌套,解決方法我會這用兩種

  1. OOP Style - namedtuple
    https://docs.python.org/3/library/collections.html#collections.namedtuple

    Result = collections.namedtuple('Result', 'code message')
    
    def f():
       return Result(code=42, message='answer')
    
    res = f()
    assert res.code == 42
    assert res.message == 'answer'
  2. Functional Style - data accessor

    def getter(*path):
       # omit error handling here for brevity's sake
       def get(collection):
           for p in path:
               collection = collection[p]
           return collection
      return get
    
    # = lambda c: c[2]['content']
    get_content = getter(2, 'content')
    # = lambda c: c[42]['foobar']
    get_foobar = getter(42, 'foobar')

多利用 python 的 first-class function 這個特點,比如這裡

linuxbar/forum.py

Lines 34 to 35 in 1b92a23

def now():
return datetime.datetime.now()

instead of

def now():
    return datetime.datetime.now()

可以這樣

now = datetime.datetime.now

沒必要多一層

if len(lst) == 0:
    pass
# 直接用
if not lst:
    pass

等等


User handling,用現有的 extension 可以省很多代碼,而且和其他 extension 和 flask 本身整合更好

最基礎的 login/logout 有 Flask-Login http://pypi.python.org/pypi/Flask-Login
用戶權限,有 Flask-Principal https://pypi.python.org/pypi/Flask-Principal
更完整的,包括 activation,重置密碼等,有 Flask-Security https://pypi.python.org/pypi/Flask-Security ,是整合了上面兩個和其他的,大概相當於 django 的 auth app


Sending email
應該異步,不要用 blocking call,否則會是個很容易 DDoS 的點,最簡單的是 multiprocessing 或 threading 的 Pool,或者 third-party 的如 celery


encryption 等
最好不要自己寫,即使 hash + salt,而且為防 timing-attack,應該用專門 hash password 的,那些會迭代很多次,最省事是用 werkzeug (Flask 依賴的 WSGI 實現)的,

from werkzeug.security import check_password_hash, generate_password_hash

Uploading
Flask-Uploads http://pypi.python.org/pypi/Flask-Uploads


User input validation, form handling
Flask-WTF http://pypi.python.org/pypi/Flask-WTF
支持 CSRF,而且和各個 ORM extension 整合很好


永遠不要信任用戶輸入,存入時應該 sanitize,去掉所有 html tags,特別是 script 和 iframe,escape sequence,處理麻煩的 unicode entity,目前的代碼就沒做好
早期貼吧就因為這些常出漏洞

一個相關的內容是,我強烈建議給用戶輸入 Markdown,並且支持 code highlighting,再 sanitize 掉有害的內容(Markdown 支持直接 html),最後再轉換 @ 的連接,除了最後一步,具體怎麼做可參考我那個 forum。

即使自己寫轉換,也很多地方可以精簡的,比如:

linuxbar/forum.py

Lines 66 to 113 in 1b92a23

for I in text.replace('\r', '').split('\n'):
if(not first_row):
new_text += '\n'
else:
first_row = False
if(I == '/***#'):
callback_on = False
new_text += '<pre class="code_block">'
continue
elif(I == '#***/'):
callback_on = True
new_text += '</pre>'
continue
line = ''
first_col = True
code_block = False
for J in I.split(' '):
if(not first_col):
line += ' '
else:
first_col = False
if(callback_on):
if(J.startswith('`') and J.endswith('`')):
line += '<code>%s</code>' % escape(J[1:-1])
continue
if(not code_block and J.startswith('`')):
line += '<code>'
line += escape(J[1:])
code_block = True
continue
if(code_block and J.endswith('`')):
line += escape(J[:-1])
line += '</code>'
code_block = False
continue
if(callback_on and not code_block):
line += entry_callback(J)
else:
line += escape(J)
if(code_block):
line += '</code>'
if(callback_on):
new_text += line_callback(line)
else:
new_text += line
if(not callback_on):
new_text += '</pre>'
return new_text

這種情況,現在是寫成 imperative 的 FSM
還不如寫成 functional 的 data pipeline
可以隨意搭配
每步處理多的用 generator 也不特別耗費資源
e.g

# in pipeline.py
def normalize_newline(text):
    return '\n'.join(text.splitlines())

BEGIN_CODE = '/***#'
END_CODE = '#***/'

def normalize_space(text):
    in_code_block = 0
    for line in text:
        if line.strip().startswith(BEGIN_CODE):
             in_code_block += 1
        if not in_code_block:
            yield ' '.join(line.split())
        else:
            yield line
        if line.strip().endswith(END_CODE):
            in_code_block -= 1

def other_transformation(text):
    for line in text:
        # blah blah blah
        yield line

def pipeline(*processors):
    def process(text):
        for proc = processors:
            text = proc(text)
        return text
    return process

# in forum.py
from .pipeline import (
    normalize_newline,
    normalize_space,
    other_transformation,
    pipeline,
)

process_content = pipeline(
    normalize_newline,
    normalize_space,
    other_transformation,
)

...
    return process_content(result[0]['content']))

當然,上面說了,這個具體情況下,直接用 Markdown parser 更方便,client side preview 有很多選擇


Rendering
代碼實在太多,我沒看完,但感覺是直接 json,然後 client side rendering,我個人看法是這個應用中不好,原因是

  • graceful degradation
    不需要實時交互的應用,不需要設計成 SPA (single-page application),不是 realtime chatting
    websocket 之類。實際上早期貼吧就是因為 noscript 都能用,沒什麼 javascript 速度快,才在各種論
    壇中突出的(還有一個是不需要註冊),而我現在越來越少去也是因為其背離了這點,不 KISS。
  • discoverbility
    基本上不用解釋了,SEO,雖然號稱 google 也會解釋 javascript

至於 RESTful API,還是可以提供,只要使用 flask extension 的 ORM 和 REST 框架,可以自動生成 API 的,一般並不需要重複寫 views


用 blueprint
http://flask.pocoo.org/docs/0.11/blueprints/


模塊化
應盡量分類放,每個 module 盡量保持 200 loc 以下,否則會嚴重影響寫代碼的 productivity
我個人的標準是不超過 100,連注釋,合理分類一般能做到,除了個別模塊,比如想將所有 Model 放在一起。

我寫過一個類似規模的 project,連 100% test coverage 的 tests 代碼,連注釋,連其他 script,總共才 3000 loc python,其中 1000 多是 tests,200 是 script。各個 module 20-80 不等,只有 models 和 views 我為了減少相互 import 故意放在一起,一個 400 多,一個 200 多,但都很好閱讀,因為都是同類的。


最後

目前這個代碼太多了,建議先合理分類好模塊,重構一下,精簡代碼,現在是 3695 行 python,我估計可以精簡至少一半,然後再看看。
我如果還有時間再看具體的地方。

@910JQK
Copy link
Owner

910JQK commented Oct 22, 2016

信息量略大……
從開始寫到現在一直都是走路不看路,結構確實亂。這幾天先精簡一下代碼,搞搞模塊化。其它的慢慢來,一點一點修。
P.S. 除了樓中樓翻頁以外沒什麽 client side rendering 了,寫一坨 API 是真不知道有庫(準確地說是沒有意識去找,各種強寫

@910JQK
Copy link
Owner

910JQK commented Oct 28, 2016

試着重構了幾天,感覺腦子要炸了,搞不下去了……
https://github.com/910JQK/linuxbar/compare/dev
事情是這樣的:我去看了看生成 API 的庫,發現難以滿足以下需求:

  • 足夠 detailed, user-friendly 的錯誤信息
  • 複雜的查詢要求(比如,刪帖只標記不刪 record / 封禁不能發貼 / 查詢未過期的封禁記錄 / 存在更長期的封禁則不予重複封禁)
  • 總之需要更高層次的封裝

然後我就想把 forum.py 模塊化,然後自動生成 API, 把重複寫的 view 去掉。結果越寫越玄乎,感覺有些東西難以處理。
比如用 None 代表「不限板塊」(全局 admin / ban),但由於 bool(None) == False, 會造成版塊不存在的錯誤,所以就 hack 來 hack 去,整個都亂了。有些不斷重複的代碼也不知道怎麽改。對這些具體問題很頭疼。各個表之間邏輯關係太多,腦子繞不過來,庫也沒法用。
感覺是各種姿勢不對。

於是我想,把原來的代碼先拆開,把要緊的問題小修一下,再把剩下的功能完成(基本就是些前端的事),先能用再說,然後再研究怎麽重構。

@pyx
Copy link
Author

pyx commented Oct 28, 2016

我沒注意到原來在 dev branch 有改動

把原來的代碼先拆開,把要緊的問題小修一下,再把剩下的功能完成(基本就是些前端的事),先能用再說,然後再研究怎麽重構。

這個會造成 technical debt,你現在代碼這麼新鮮都看/改不下去,六個月後的自己更無法理解了,也就是說,一個根據經驗很合理的推測是,現在這個 code base,開發者以後基本上很難會花比現在少的力氣改進,或者在這基礎上更加一些新功能,或者定位查找 bugs,換言之,無法維護。

因爲我上次留言還沒看到 README,沒法本地測試(這就是項目架構先搞好的重要性,我想測試一下,發些小 pull request,沒門路才開始寫上面一大段的),不知道完成度怎麼樣,如果你覺得基本功能都完成了,那我覺得最好的下一步是重寫,完全重頭來。在沒有 deadline 的情況下,依照之前寫過的經驗,重寫一個往往比全盤重構更省事,而且最終會得到更好的代碼,因爲之前走過的(時間和設計上的)彎路都能避免了。

一步步重頭來過,看似麻煩,其實更快,如果是我,會這樣做

如果之前沒試過,先通讀一遍 Flask 的文檔,包括後面的 cookbook 和 extension 介紹,需時大概一兩天吧,主要瞭解有什麼提供的和有什麼注意事項,best practice 什麼的,效果達到需要時知道大概哪裏找出來看細節就行。(題外話,閱讀也能提高 coding 水平的,我近十年來的提高,大量閱讀是最重要的因素)

  1. 選個好點的名字,PyPI 上沒人用的,先註冊下來,以後不用改了,linuxbar 有點 low
  2. 項目基本文件和目錄結構先弄好,比如 README, LICENSE, requirements.txt,目的是方便構建開發環境
  3. 寫個基本 app,就一個 index,return 'hello world' 就行
  4. 數據庫設計好,寫 tests,我推薦用 py.test,主要測試基本操作,比如用戶建立啦(可以先不考慮驗證),用戶發帖啦,用戶編輯帖子啦,暫時先不測試權限
  5. 可以考慮先用 Flask-Login 弄好簡單的 session-based 的登錄部分,做成一個 blueprint,叫 auth 之類的,然後註冊到 app,比如掛在 '/auth' 下面,然後將這個依賴添加到 requirements.txt,這個過程也可以用 TDD 的方法,先寫好 tests。
  6. 重複 5,分別實現 board 的顯示,admin,註冊,用戶 profile,每個都是一個 blueprint,逐個添加

雖然目錄架構,各文件等沒有 100% 通用的標準,相信你看過我上面發的那個論壇應該對你怎麼構建項目的結構(文件,模塊等)會有幫助。

由於具體代碼我不方便公開,我貼一些片段,或許可以給你一些靈感

例如測試 user model 的 tests/test_user.py 全文是:

# -*- coding: utf-8 -*-
import pytest
import sqlalchemy.exc

from carucate.models import db, User


def test_create_user(app):
    john = User(email='john@example.com', password='1234')
    db.session.add(john)
    db.session.commit()
    user = User.query.filter_by(email='john@example.com').one()
    assert user.check_password('1234')


def test_unique_user_email(app):
    john = User(email='john@example.com', password='1234')
    db.session.add(john)
    db.session.commit()
    john2 = User(email='john@example.com', password='5678')
    db.session.add(john2)
    with pytest.raises(sqlalchemy.exc.IntegrityError):
        db.session.commit()


def test_user_password_unreadable(app):
    john = User(email='john@example.com', password='1234')
    with pytest.raises(AttributeError):
        'password is: ' + john.password


def test_user_check_password(app):
    john = User(email='john@example.com', password='1234')
    assert john.check_password('1234')
    assert not john.check_password('1234.')

例如 測試 auth 這個 blueprint 的 views 大約是這樣的:

# -*- coding: utf-8 -*-
import re

from flask import url_for

from carucate.mail import mail
from carucate.models import db, User
def parse_url(text):
    return re.findall(r'http://localhost/\S+', text)[0]


def test_change_email(app, user, login):
    with app.test_client() as client, mail.record_messages() as outbox:
        login(client)
        response = client.post(
            url_for('auth.change_email'),
            follow_redirects=True,
            data=dict(email='jane@example.com'))
        html = response.data.decode()
        assert 'confirmation email has been sent to you' in html

        response = client.get(url_for('auth.logout'), follow_redirects=True)
        html = response.data.decode()
        assert 'Logged out.' in html

        assert len(outbox) == 1
        url = parse_url(outbox[0].body)
        response = client.get(url, follow_redirects=True)
        html = response.data.decode()
        assert response.status_code == 200
        assert 'email address confirmed' in html

        response = client.post(
            url_for('auth.login'),
            follow_redirects=True,
            data=dict(email='john@example.com', password='1234'))
        html = response.data.decode()
        assert 'Invalid email or password' in html

        response = client.post(
            url_for('auth.login'),
            follow_redirects=True,
            data=dict(email='jane@example.com', password='1234'))
        html = response.data.decode()
        assert 'Logged in successfully.' in html


def test_change_email_with_get_should_fail(app, user, login):
    with app.test_client() as client:
        login(client)
        response = client.get(
            url_for('auth.change_email'),
            follow_redirects=True,
            query_string=dict(email='jane@example.com'))
        assert response.status_code == 200
        assert User.query.one().email == 'john@example.com'


def test_change_password(app, user, login):
    with app.test_client() as client:
        login(client)
        response = client.post(
            url_for('auth.change_password'),
            follow_redirects=True,
            data=dict(password='incorrect', newpass='4321', confirm='4321'))
        html = response.data.decode()
        assert 'Incorrect password' in html
        assert user.check_password('1234')

        client.post(
            url_for('auth.change_password'),
            follow_redirects=True,
            data=dict(password='1234', newpass='4321', confirm='4321'))
        assert user.check_password('4321')

...


def test_invalid_activation_token(app):
    with app.test_client() as client:
        response = client.get(
            url_for('auth.activate_account', token='invalid'),
            follow_redirects=True)
        assert response.status_code == 404


def test_invalid_login(app):
    with app.test_client() as client:
        response = client.post(
            url_for('auth.login'),
            follow_redirects=True,
            data=dict(email='john@example.com', password='1234'))
        html = response.data.decode()
        assert 'Invalid email or password' in html


def test_login_logout(app):
    john = User(email='john@example.com', password='1234')
    john.is_active = True
    john.email_confirmed = True
    db.session.add(john)
    db.session.commit()
    with app.test_client() as client:
        response = client.post(
            url_for('auth.login'),
            follow_redirects=True,
            data=dict(email='john@example.com', password='1234'))
        html = response.data.decode()
        assert 'Logged in successfully.' in html

        response = client.get(url_for('auth.logout'), follow_redirects=True)
        html = response.data.decode()
        assert 'Logged out.' in html

...

比如 auth blueprint 的 views (auth/views.py) 裏部分內容是這樣的

from flask import (
    Blueprint,
    abort,
    current_app,
    flash,
    redirect,
    render_template,
    request,
    url_for,
)
from flask_login import (
    LoginManager,
    current_user,
    login_required,
    login_user,
    logout_user,
)
from itsdangerous import URLSafeTimedSerializer

from .forms import (
    ChangeEmailForm,
    ChangeNameForm,
    ChangePasswordForm,
    ForgetPasswordForm,
    LoginForm,
    ResetPasswordForm,
    SignupForm,
)
from .. import mail
from ..constants import DEFAULT_TOKEN_MAX_AGE, MAX_USER_NAME_LENGTH
from ..models import User, db


login_manager = LoginManager()
login_manager.login_view = 'auth.login'


@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))


def init_app(app):
    login_manager.init_app(app)

auth = Blueprint('auth', __name__)

...

@auth.route('/activate/<token>/')
def activate_account(token):
    salt = current_app.config['CARUCATE_ACTIVATION_SALT']
    max_age = current_app.config['CARUCATE_ACTIVATION_TOKEN_MAX_AGE']
    email = validate_token_or_404(token, salt, max_age)
    user = User.query.filter_by(email=email).first_or_404()
    user.email_confirmed = True
    user.is_active = True
    db.session.add(user)
    db.session.commit()
    flash('account activated, please login with your email')
    return redirect(url_for('.login'))

...

@auth.route('/login/', methods=['GET', 'POST'])
def login():
    if not current_user.is_anonymous:
        return redirect(url_for('home.index'))

    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if check_password(user, form.password.data):
            login_user(user, form.remember_me.data)
            flash('Logged in successfully.')
            next_ = request.args.get('next')
            return redirect(next_ or url_for('dashboard.index'))
        flash('Invalid email or password')
    return render_template('auth/login.html', form=form)


@auth.route('/logout/')
@login_required
def logout():
    logout_user()
    flash('Logged out.')
    return redirect(url_for('.login'))

...

而 app.py 全文裏是大概這樣的

from flask import Flask
from flask_gravatar import Gravatar
from flask_moment import Moment
from flask_pure import Pure
from flask_simplemde import SimpleMDE
from .config import DefaultConfig
from .auth.views import auth, init_app as auth_init_app
...
from .dashboard.views import dashboard
...
from .group.views import group
from .home.views import home
...
from .user.views import user
from .mail import init_app as mail_init_app
from .models import init_app as models_init_app


def create_app(config=None, config_filename=None):
    """application factory"""
    app = Flask(__name__, instance_relative_config=True)
    if config is None:
        config = DefaultConfig
    app.config.from_object(config)
    if config_filename is not None:
        app.config.from_pyfile(config_filename)
    app.config.from_envvar('CARUCATE_SETTINGS', silent=True)
    mail_init_app(app)
    models_init_app(app)
    auth_init_app(app)
    Gravatar(app, default='identicon')
    Moment(app)
    Pure(app)
    SimpleMDE(app)
    app.register_blueprint(auth, url_prefix='/auth')
    ...
    app.register_blueprint(dashboard, url_prefix='/dashboard')
    ...
    app.register_blueprint(group, url_prefix='/groups')
    app.register_blueprint(home)
    ...
    app.register_blueprint(user, url_prefix='/user')
    return app

部分代碼大小,這是功能完整的了,就是我前面提到的原則,實際上每個 module 超過 200 loc 就變得難以查找和理解(除非是 utils 這類每個 function 之前沒有很緊密結合的工具 module)
Python 足夠高級以至於大多數情況下,設計良好的 module 都可以在這個範圍

wc -l `hg manifest | grep '\.py'`
    0 carucate/__init__.py
   50 carucate/app.py
    0 carucate/auth/__init__.py
   67 carucate/auth/forms.py
  235 carucate/auth/views.py
   ...
   36 carucate/config.py
   53 carucate/constants.py
    0 carucate/dashboard/__init__.py
   13 carucate/dashboard/views.py
   52 carucate/decorators.py
   ...
    9 carucate/forms.py
    0 carucate/group/__init__.py
   52 carucate/group/forms.py
   65 carucate/group/views.py
    0 carucate/home/__init__.py
   28 carucate/home/views.py
   ...
   43 carucate/mail.py
   11 carucate/mixins.py
  414 carucate/models.py
   ...
    0 carucate/user/__init__.py
   25 carucate/user/views.py
   34 carucate/utils.py
   35 fabfile.py
   84 manage.py
   30 scripts/create_dev_config.py
   30 scripts/create_production_config.py
   26 setup.py
   ...

@pyx
Copy link
Author

pyx commented Oct 28, 2016

我覺得帖子的回覆,要麼就傳統論壇的引用,要麼就支持多層嵌套,像貼吧那種只有一層樓中樓的,我認爲是高不成低不就的做法。

多層嵌套就是每個帖子的 model 做成樹的形式,爲了避免多次遞歸造成的訪問數據庫次數過多,有好些方法避免,上面貼的那個片段的項目我用的是其中一種叫 materialized path,簡單的說就是不用自引用的 foreign key 而是用一個專門的 field 來記錄 path,比如 path='123/456/789',其中數字是 id。materialized path 非常適合用在支持多層嵌套的帖子,排序,插入,刪除,查找子樹,都很低 overhead。

@1dot75cm
Copy link

1dot75cm commented Oct 29, 2016

那么窝们就先来规划一下, 然后 PR 什么的慢慢来咯?
窝建议就直接用 Flask web dev 那本书的作者的示例代码结构, 风格非常好哇。 窝自己照着学习一遍都很有收获 https://github.com/1dot75cm/flasky

Mark @pyx 大神的建议好实用

@910JQK
Copy link
Owner

910JQK commented Oct 30, 2016

待我慢慢研究研究……

@pyx
Copy link
Author

pyx commented Nov 12, 2016

強烈建議 @1dot75cm 的建議,通讀一遍 Flask Web Development,再開始寫,如果找不到本書,作者有一系列的 blog post,書的內容就是從那裡提煉出來的。

裡面有不少好的建議,包括怎樣組織項目結構,選用 extensions,best practices,雖然有些建議我不見得認同[1],但 Flask 和其他 full stack (Django,RoR,etc.) 比起來,就是 all about choices,值得一看。

[1]:

  • 比如 unit testing,我會建議用 py.test,fixtures 真的很好用,而且沒有繁瑣的 java 風格什麼都塞 class 裡的弊病,直接 test functions 和 assert,還有很多好用的插件,如 test coverage。
  • 比如 markdown editor,flask-pagedown 的 editor 沒有 responsive 設計,於是我發現了另外一個更好的叫 SimpleMDE,我自己封裝成一個 flask extension,Flask-SimpleMDE 方便使用。
  • 比如我更喜歡輕量的 css framework,所以我用 Purecss,我也封裝成一個 extension,Flask-Pure
    這兩個 extensions 也都在 PyPI。

@1dot75cm
Copy link

窝刚把 Flask 资源汇总了一下,供参考。 @910JQK
https://github.com/1dot75cm/awesome-flask-cn/blob/master/README-cn.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants