ブログなどに掲載するコードが動かないことがしばしばあるので、どうにかできないかなあと考えました。
- コードをテストできればいいんじゃね?
- 外部ファイルにテスト書いて、必要な部分だけインクルードすればテストできそう。
ということで、部分的にMarkdownをincludeでき(かつqiitaに投稿しちゃう)コマンドqiitap を作りました。この記事はこのような問題に僕が一人で寝る前のちょっとした時間を惜しんで取り組んだ試行錯誤の記録です。
こちらには記述方法のサンプルがあります。http://qiita.com/TakesxiSximada/items/6fa704dffd25dce635e2
このコードスニペット、動かない!?
技術系ブログにはプログラムをどう書けばいいかを説明するためにコードスニペット(コードの切れ端)がよく用いられます。コードスニペットがあると読み手はそのコードをターミナルやファイルにコピー&ペーストして動作することを手軽に確認できます。
def hello():
print('Hello')
ello()
しかしコードスニペットが正しく動作しないことがあります。環境の違いによるものもありますが、最も典型的なミスはコピー&ペーストした時の作業ミスで意図しない文字を挿入してしまっていたり、逆に挿入すべき文字がうまくペーストできなかったすることです。上記のコードスニペットにはコピペミスがあります。どこかわかりますか? このようなコードスニペットは当然正しく動作しません。読者は動かないコードスニペットを前にして、ただ呆然と立ち尽くし、混乱のうちにやがて朽ちて果てるでしょう。
それではあんまりですよね?
コピー&ペーストのミスで動かないとしましたが、本当にそうでしょうか?コピー&ペーストでミスをしても記事にした後でコードをテストすれば動かないことに気づきます。気付けば修正するので、そのまま掲載されているということは、気づいていない/テストしていないということになります。
ではなぜテストしないのでしょうか?それはズバリ面倒だからでしょう。記事内のコードスニペットをテストするためには、再度コピーしてターミナルやファイルにペーストしなければなりません。
コマンド一つでテストを実行というわけにはいかないので、テストは当然しないでしょう。テストをしにくい状態が出来上がっています。
コードスニペットをテストしやすくするには?
ではコードスニペットはどうすればテストしやすくなるのでしょうか? Markdown内に記載されたコードスニペットを抜き出してそれぞれの処理系で実行すれば動作の確認はできます。次の手順で処理を行うことになるでしょう。
- Markdownをパースする
- スニペット部分を抜き出す
- スニペット部分が何で書かれているかを判定する
- 処理系にコードスニペットを流す
気が遠くなりますね。心が折れてしまいます。
他の問題もあります。テストコードにはコードスニペットとして表示したくない箇所があります。例えばMockオブジェクトの作成や、データ作成のための前処理、assertなどの結果の確認です。これらはテストコードが正しく動いているかを確認するためのものなので記事上に掲載すると読者はますます混乱してしまいます。
ではテストコードを外部のファイルに記述し、記事用のMakrdownにはそれを読み込むためのinclude命令を追加するのはどうでしょう。コードスニペットはそれぞれの処理系のユニットテストの記述方法で記述することで、テストはし易くなるでしょう。通常のincludeの機能のようにファイルの中身全てをincludeしてしまうと、Mockオブジェクトの作成や、データ作成のための前処理、assertなどの結果の確認
まで表示してしまうことになるので、includeするファイルと位置が指定できると良さそうです。
Sphinxのliteralincludeディレクティブは範囲を指定してincludeできる
Python系のプロジェクトでよく使われるドキュメンテーションツールSphinxにはliteralincludeというディレクティブがあります。これはディレクティブを記述している箇所に、引数として指定したファイルの内容を挿入します。しかもliteralincludeは挿入する範囲を:start-after:
や:end-before:
をパラメータで絞ることができます。例えば次のテストコードtest_it.pyがあるとします。
import unittest
class SimpleTest(unittest.TestCase):
def test_it(self):
# [START]
import json
text = json.dumps({})
# [END]
self.assertEqual('{}', text)
このファイルはテストコードなので、全てをドキュメントに含めたくはありませんが、[START]
と [END]
で挟まれたコードは実際の使い方を表しているので、ドキュメントに含めたいです。Sphinxのliteralincludeディレクティブを使うと、この箇所だけを抜き出してドキュメントに含められます。:start-after:
に開始位置を示す文字列を、:end-before:
に終了位置を示す文字列を指定します。
.. literalinclude:: ./test_it.py
:start-after: [START]
:end-before: [END]
rstファイルはこのようになります。
Welcome to tes's documentation!
===============================
.. toctree::
:maxdepth: 2
:caption: Contents:
.. literalinclude:: ./test_it.py
:start-after: [START]
:end-before: [END]
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
index.rstをHTMLに変換するとこのようなページが生成されます。そしてtest_it.pyははユニットテストとしても正しく動作します。
$ python -m unittest
.
----------------------------------------------------------------------
Ran 1 test in 0.084s
OK
Sphinxは良いツールなのですが、SphinxでRestructuredTextをビルドするにはconf.py作らないといけないし、標準出力に変換後のMarkdownを出力してパイプでつなぐ処理もやりづらいので今回はSphinxを使うのを諦めました。
じゃあどうするのか?
Markdownの処理ツールによってはincludeをサポートしているものもあります。HugoのPartialなどはそれに当たるでしょう。今回はQiitaに投稿することを前提としているのでHTMLではなくMarkdownで出力してほしいのです。ということはMarkdownの処理系ではなくただのテンプレートエンジンで用件は満たせそうです。また特定のタグに挟まれた箇所だけを抜き出したいのでそのようなinclude処理を簡単に変更できるテンプレートエンジンが望ましいです。現時点で使い慣れているテンプレートエンジンだとMakoとJinja2でした。Makoの方がより自由度の高いテンプレートエンジンのため、今回はMakoを使ってMarkdownの前処理をします。
Makoでなんとかする
Mako はPythonでよく使われるテンプレートエンジンの一つです。どうでもいいですがGoogleでMakoを検索すると声優さんのWikipediaのページがトップに出てきますが、それは関係ありません。
Makoのinclude機構
Makoにはinclude機構があります。以下はincludeタグの簡単な使い方の例です。
<%include file='test_sphinx/test_it.py' />
このようにincludeタグを使うことでファイルをインクルードできます。レンダリングは以下のようにします。
from mako.tempate import Template
with open('/path/to/your/markdown', 'rb') as fp
tmpl = Template(fp.read())
markdown_content = tmpl.render()
markdown_content
には前処理されたMardownの文字列が格納されます。ただしこのincludeタグは開始位置と終了位置を指定できません。
通常のincludeの機能のようにファイルの中身全てをincludeしてしまうと、
Mockオブジェクトの作成や、データ作成のための前処理、assertなどの結果の確認
まで表示してしまうことになるので、includeするファイルと位置が指定できると良さそうです。
前述したようにファイルと開始位置と終了位置を指定したいので、指定可能なカスタムタグを作成します。
開始/終了位置が指定できるカスタムincludeタグを作成する
MakoではカスタムタグはPythonで書きます。callableなオブジェクトであればカスタムタグとして使えます。第一引数には必ずファイルライクオブジェクトが渡されます。それ以外の引数は任意で使えます。そのため、第一引数にcontext、第二引数に開始位置を示す文字列、第三引数に終了位置を示す文字列をわたすようにします。
開始位置と終了位置を探すには1行ずつなめるようにしました。
with open(path) as source:
for ii, line in enumerate(source):
if not is_content and start_at in line:
is_content = True
if line != '' and line[0] in (' ', '\t'):
truncate_char = line[0]
truncate_count = len(line) - len(line.lstrip(truncate_char))
elif is_content and end_at is not None and end_at in line:
is_content = False
break
elif is_content:
fp.write(line)
この時、開始位置の左側の空白の数を数えています。これは後でその空白を削除して左寄せするためです。以下では左寄せを行なっています。
for line in fp:
truncate_line = line[:truncate_count] if truncate_char else line
writable_line = line[truncate_count:]
if truncate_char:
if line.strip() == '':
writable_line = '\n'
elif len(truncate_line) != truncate_line.count(truncate_char):
raise TruncateError(
'Truncate failed: len={}, count={}: start={}, end={}: {}'.format(
len(truncate_line), truncate_line.count(truncate_char),
start_at, end_at, repr(truncate_line)))
context.write(writable_line)
カスタムタグを定義したファイルはこのようになります。
import six
if six.PY2:
from StringIO import StringIO
else:
from io import StringIO
class TruncateError(Exception):
pass
def custom_include(context, path, start_at='', end_at=None):
truncate_count = 0
truncate_char = ''
fp = StringIO()
is_content = False
# [POSTION_INCLUDE_START]
with open(path) as source:
for ii, line in enumerate(source):
if not is_content and start_at in line:
is_content = True
if line != '' and line[0] in (' ', '\t'):
truncate_char = line[0]
truncate_count = len(line) - len(line.lstrip(truncate_char))
elif is_content and end_at is not None and end_at in line:
is_content = False
break
elif is_content:
fp.write(line)
# [POSTION_INCLUDE_END]
fp.seek(0)
# [LEFT_START]
for line in fp:
truncate_line = line[:truncate_count] if truncate_char else line
writable_line = line[truncate_count:]
if truncate_char:
if line.strip() == '':
writable_line = '\n'
elif len(truncate_line) != truncate_line.count(truncate_char):
raise TruncateError(
'Truncate failed: len={}, count={}: start={}, end={}: {}'.format(
len(truncate_line), truncate_line.count(truncate_char),
start_at, end_at, repr(truncate_line)))
context.write(writable_line)
# [LEFT_END]
return ''
では次のサンプルファイルをカスタムタグで読み込んでみましょう。
AAAAAAAAAAAAAA
AAAAAAAAAAAAAA
AAAAAAAAAAAAAA
[START]
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
[END]
AAAAAAAAAAAAAA
AAAAAAAAAAAAAA
カスタムタグを使う場合は以下のようにします。
<%namespace name="custom_tag" module="custom_tag"/>
${custom_tag.custom_include("./fish.txt", "[START]", "[END]")}
出力はこのようになります。
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
BBBBBBBBBBBBB
良さそうです。
#マークが消える?
Makoでレンダリングしてたら、見出しが消えていることに気がつきました。
### これとか
#### これとか
##### これとか
一方消えなかった見出しもありました。
# これは消えなかった
どうやらh2以上の見出しが消えてしまっていました。理由は簡単で ##
はMakoがコメントとして解釈していました。そのためレンダリングされず見出しが消えてしました。コメントの書き方は ##
と <%doc></%doc>
を使う書き方があります。後者の方は使いたいケースもあると思うので、##
だけをコメントとして解釈しないようにMakoの挙動を変更します。
Makoのトークン解析はmako.lexer.Lexer()で実装されています。制御要素はmako.lexer.Lexer.match_control_line()で解析されています。実際にこのメソッドの先頭にそれっぽい正規表現が見て取れます。
def match_control_line(self):
match = self.match(
r"(?<=^)[\t ]*(%(?!%)|##)[\t ]*((?:(?:\\r?\n)|[^\r\n])*)"
r"(?:\r?\n|\Z)", re.M) match = self.match(
ぱっと見たところ ##
が正規表現に記述されています。明らかにこれなので消してしまえば良さそうです。しかし正規表現はメソッド内部で記述されているため、メソッドをコピーして実装するしか手はなさそうです(悲しい)。MarkdownLexerを新たに定義してmatch_control_lineメソッドをoverrideしてしまいます。
class MarkdownLexer(Lexer):
def match_control_line(self):
# <MATCH_CONTROL_RE>
match = self.match(
r"(?<=^)[\t ]*(%(?!%))[\t ]*((?:(?:\\r?\n)|[^\r\n])*)"
r"(?:\r?\n|\Z)", re.M)
# </MATCH_CONTROL_RE>
if match:
operator = match.group(1)
text = match.group(2)
if operator == '%':
m2 = re.match(r'(end)?(\w+)\s*(.*)', text)
if not m2:
raise exceptions.SyntaxException(
"Invalid control line: '%s'" %
text,
**self.exception_kwargs)
isend, keyword = m2.group(1, 2)
isend = (isend is not None)
if isend:
if not len(self.control_line):
raise exceptions.SyntaxException(
"No starting keyword '%s' for '%s'" %
(keyword, text),
**self.exception_kwargs)
elif self.control_line[-1].keyword != keyword:
raise exceptions.SyntaxException(
"Keyword '%s' doesn't match keyword '%s'" %
(text, self.control_line[-1].keyword),
**self.exception_kwargs)
self.append_node(parsetree.ControlLine, keyword, isend, text)
else:
self.append_node(parsetree.Comment, text)
return True
else:
return False
変更点は ##
を正規表現から抜いただけです。
match = self.match(
r"(?<=^)[\t ]*(%(?!%))[\t ]*((?:(?:\\r?\n)|[^\r\n])*)"
r"(?:\r?\n|\Z)", re.M)
このmako.template.Template()はコンストラクタの引数としてLexerオブジェクトのファクトリを渡すことができます(いいですねえ)。以下は実際のレンダリングの例です。
lookup = TemplateLookup(directories=['.'])
tmpl = Template(body, lookup=lookup, lexer_cls=MarkdownLexer)
renderd_body = tmpl.render(attr=attributes)
Qiitaに記事を投稿する
生成したMakrdownをQiitaに投稿する
QiitaはAPI経由で記事を投稿できます。その気になればcurlを使って投稿もできます。新規投稿に関しては Qiita API v2ドキュメント - Qiita:Developer に仕様が記載されています。どうやら以下のようなbodyを送信する必要がありそうです。
{
"body": "# Example",
"coediting": false,
"gist": false,
"private": false,
"tags": [
{
"name": "Ruby",
"versions": [
"0.0.1"
]
}
],
"title": "Example title",
"tweet": false
}
Qiita API v2ドキュメント - Qiita:Developer を抜粋
bodyとtitleは指定する必要がありそうですが、他のパラメータは後で考えれば良さそうです。試しにcURLでPOSTしてみます。
$ curl -X POST https://qiita.com/api/v2/items --data @create.json
{"message":"Unauthorized","type":"unauthorized"}%
Unauthorized
になってしまいました。どうやら認証処理が必要なようです。それはそうですよねえ。Qiita API v2ドキュメントの認証認可 には以下のようにあります。
アクセストークン
アクセストークンは、以下のようにAuthorizationリクエストヘッダに含められます。
Authorization: Bearer 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd
Qiitaの管理画面からアクセストークンを発行し、それをHTTPヘッダに含める必要がありそうです。アクセストークンは https://qiita.com/settings/applications で発行できます。発行したアクセストークンを使ってQiitaに投稿してみます。
$ curl -X POST https://qiita.com/api/v2/items --data @create.json -H "Authorization: Bearer MASKMASKMASKMASKMASKMASKMASKMASK"
{"message":"Invalid content type","type":"invalid_content_type"}%
Invalid content type
になってしまいました。bodyがJSONなのでapplication/jsonにするべきでしたね。Content-Type
ヘッダを追加して再度投稿します。
curl -X POST https://qiita.com/api/v2/items --data @create.json -H "Authorization: Bearer MASKMASKMASKMASKMASKMASKMASKMASK" -H "Content-Type: application/json"
{"rendered_body":"\n\u003ch1\u003e\n\u003cspan id=\"example\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#example\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eExample\u003c/h1\u003e\n","body":"# Example\n","coediting":false,"created_at":"2017-01-21T16:34:03+09:00","group":null,"id":"xxxxxxxxxxxxxxx","private":false,"tags":[{"name":"Ruby","versions":["0.0.1"]}],"title":"Example title1","updated_at":"2017-01-21T16:34:03+09:00","url":"http://qiita.com/TakesxiSximada/items/4d12097001d4ab8983c6","user":{"description":"","facebook_id":"","followees_count":2,"followers_count":21,"github_login_name":"TakesxiSximada","id":"TakesxiSximada","items_count":38,"linkedin_id":"","location":"","name":"Takesxi Sximada","organization":"","permanent_id":36261,"profile_image_url":"https://qiita-image-store.s3.amazonaws.com/0/36261/profile-images/1473687011","twitter_screen_name":"TakesxiSximada","website_url":""}}%
レスポンスを見やすくしてみます。
{
"rendered_body": "\n<h1>\n<span id=\"example\" class=\"fragment\"></span><a href=\"#example\"><i class=\"fa fa-link\"></i></a>Example</h1>\n",
"body": "# Example\n",
"coediting": false,
"created_at": "2017-01-21T16:34:03+09:00",
"group": null,
"id": "xxxxxxxxxxxxxxxxxxxxxxx",
"private": false,
"tags": [
{
"name": "Ruby",
"versions": [
"0.0.1"
]
}
],
"title": "Example title1",
"updated_at": "2017-01-21T16:34:03+09:00",
"url": "http://qiita.com/TakesxiSximada/items/4d12097001d4ab8983c6",
"user": {
"description": "",
"facebook_id": "",
"followees_count": 2,
"followers_count": 21,
"github_login_name": "TakesxiSximada",
"id": "TakesxiSximada",
"items_count": 38,
"linkedin_id": "",
"location": "",
"name": "Takesxi Sximada",
"organization": "",
"permanent_id": 36261,
"profile_image_url": "https://qiita-image-store.s3.amazonaws.com/0/36261/profile-images/1473687011",
"twitter_screen_name": "TakesxiSximada",
"website_url": ""
}
}
投稿できました。適当に作成したcreate.json
を用いましたが、これをMarkdownから生成すれば良さそうです。
titleを記事内に埋め込む
Sphinxでは以下のような形式でタイトルを指定できます。
.. title:: Page title
Pelicanでは以下のような形式でタイトルを指定できます。
Title: My super title
Jekyllでは以下のような形式でタイトルを指定できます。
---
title: Oh! My JK
---
辛いです。そのままQiitaに投稿してしまうとページに表示されてしまいます。ページには表示して欲しくないので、Mako Styleの最初のコメント要素にYAML形式でタイトルを保持するようにしました。
<!--- title: [WIP] 記事内のコードスニペットをテストする --->
タイトルの解析は別途やることにしました。
title_regx = re.compile(r'<!--- title:(?P<title>.*) --->')
title_line = args.source.readline()
matching = title_regx.search(title_line)
title = matching.group('title').strip() if matching else 'NO TITLE'
先頭の1行決め打ちでタイトルとかダサいので先頭のコメントに属性を設定するように修正
上記の実装だと先頭の1行しかタイトルの設定はできません。しかも他の属性を設定することもできないので拡張に乏しい実装です。あまり良くないので実装し直します。Makoで解析した時の先頭のコメント要素にYAML形式で属性値を記述することにしました。
<%doc>
title: "[WIP] 記事内のコードスニペットをテストする"
item_id: "dba9c0ab96311ab4c0c3"
</%doc>
この属性はファイルの先頭(正確にはファイル内のコメント要素の1個目)でなければなりません。レンダリング処理とは別にLexerを使ってファイルをパースして、最初のコメント要素を取得しています。中の文字列はYAML(辞書)で記述されていることを前提にしていて、YAMLパーサで解釈されます。
def get_attributes(body):
lexer = MarkdownLexer(body)
tmpl = lexer.parse()
for node in tmpl.nodes:
if isinstance(node, mako.parsetree.Comment):
return yaml.safe_load(node.text)
return {}
こうすることでtitle以外も属性を設定可能になりました。またrender()にこのオブジェクトを渡すことでMarkdown内でも属性値を使えるようになっています。
Qiita記事の更新用JSONを生成する
記事の投稿と同様、APIを使って記事を更新できます。API仕様はQiita API v2ドキュメント - Qiita:Developer に記載されています。
attributes = get_attributes(body)
title = attributes.get('title')
if not title:
raise NoTitleError()
# <RENDER>
lookup = TemplateLookup(directories=['.'])
tmpl = Template(body, lookup=lookup, lexer_cls=MarkdownLexer)
renderd_body = tmpl.render(attr=attributes)
# </RENDER>
return {
"body": renderd_body,
"coediting": False,
"gist": False,
"private": False,
"tags": [
{
"name": "Qiita",
"versions": [
"0.0.1"
],
},
],
"title": title,
}
この処理を使うと次のようなJSONを生成できます。
$ python qiita_json.py index.md | jq
{
"body": "\n\n\n\n# このコードスニペット、動かない!?\n\n技術系ブログにはプログラムをどう書けばいいかを説明するためにコードスニペット(コードの切れ端)がよく用いられます。コードスニペットがあると読み手はそのコードをターミナルやファイルにコピー&ペーストして動作することを手軽に確認できます。\n\n```py:コードスニペットの例\ndef hello():\n print('Hello')\n\nell
~~~ 省略 ~~~
py3:MarkdownをレンダリングしてJSONSerializableなオブジェクトを返す\nattributes = get_attributes(body)\ntitle = attributes.get('title')\nif not title:\n raise NoTitleError()\n\n# <RENDER>\nlookup = TemplateLookup(directories=['.'])\ntmpl = Template(body, lookup=lookup, lexer_cls=MarkdownLexer)\nrenderd_body = tmpl.render(attr=attributes)\n# </RENDER>\n\nreturn {\n \"body\": renderd_body,\n \"coediting\": False,\n \"gist\": False,\n \"private\": False,\n \"tags\": [\n {\n \"name\": \"Qiita\",\n \"versions\": [\n \"0.0.1\"\n ]\n }\n ],\n \"title\": title,\n}\n\n```\n",
"coediting": false,
"gist": false,
"private": false,
"tags": [
{
"name": "Qiita",
"versions": [
"0.0.1"
]
}
],
"title": "[WIP] 記事内のコードスニペットをテストする"
}
このJSONをcURLを使って更新してみます。
python qiita_json.py index.md | curl -X PATCH https://qiita.com/api/v2/items/dba9c0ab96311ab4c0c3 --data @- -H "Authorization: Bearer MASKMASKMASKMASKMASKMASK" -H "Content-Type: application/json"
更新できましたか?記事更新はPOSTではなくてPATCHでリクエストを送信するのがポイントです。
CLIツールまで実装する (最後の方、投げやり)
必要な機能はこれまでで大体実装できました。ただパッケージ化してCLIツールを書くまでが遠足です。最後までちゃんとやりましょう。
仕様はこんな感じ?
- 認証機能: auth
- ブラウザを開く
- トークンを生成する
- ~/.qiitap/token にトークンを保持する
- 記事作成機能: create [FILEPATH] [--title FILEPATH] [--public] [--tweet] [--gist] [--tag]
- 新規に記事を作成する
- FILEPATHにテンプレートMarkdownを生成する
- ヘッダーに記事IDを挿入する
- ヘッダーにタイトルを挿入する
- ヘッダーにタグを挿入する
- 記事更新機能: update [FILEPATH]
- ヘッダーの情報を元に記事を更新する
qiitap
ということで作りました。その名もqiitapです。
インストール
まずインストールします。
$ pip install qiitap
qiitapコマンドが使えるようになります。
Token情報などを入力
Token発行ページをbrowserでひらき、Token情報などを聞いてくるので入力します。
$ qiitap auth
聞いてくる項目は2つです。
- API Token
- デフォルトで使うタグ
--no-browser
を指定するとブラウザを開きません。
記事の新規作成
記事を作成します。
$ qiitap create
qiita上には限定共有投稿で記事が作られ、ローカルにはqiita.md
が生成されます。
記事の公開
記事を編集し公開します。qiita.md
でprivate: True
なっていることろをFalse
に書き換えます。
private: False
記事を更新します。
$ qiitap update
更新されましたか?現在はPython3にしか対応してません。多分2への対応はしないと思う(だるいから)。こんな誰得感あるもののために結構時間使ってしまったなあ。寝る前のちょっとした時間を大幅にオーバーしてしまった。もう、疲れたよ。