続:Elasticsearch のバルクロード用JSON Lines ファイルをselectする toy スクリプト(ソートなど追加)

概要

この記事は次の記事の続きです。Elasticsearchのバルクロード用のJSON LinesファイルをイメージしたJSONの簡易フィルターコマンド相当のPythonでのツール例です。

経緯は下記の記事のとおりです。いわゆる拙作ではありますが、前回記事を書いたのちに自分の中で意外に便利な気がしたので、他にもバリエーションを追加してみました。

itdepends.hateblo.jp

インプットファイルは、次のリンク先の形式をイメージしています。以降コマンド例が出てきますが、json.jsonlinesというファイル名で保存されている想定です。

www.elastic.co

ツール例

以下ツール例です。

全体的に、

{"a":{"b":{"c":100}}}

というJSONがあれば、

cのフィールドに対して

「a.b.c」

のドット表記でフィールドを指定できるようなインタフェースになっています。

1. 命名 jsonlineselect (フィールド値が所定のものをselect)

def getobj(jsonstr, keystr, force_str=True):
    import json
    wrk = json.loads(jsonstr)
    for i in keystr.split('.'):
            if wrk.get(i):
                wrk = wrk[i]
            else:
                wrk = '' #ここはこのツールの限界になりうるがひとまずこの挙動としておく
                break             
    return str(wrk) if force_str else wrk

if __name__ == "__main__":
    import sys
    import re

    """
    cat json.jsonlines | python3 thisapp.py either index._id '^1'  first '^jo.*'
    """

    mode = sys.argv[1] #both/either/each
    
    ii = sys.argv[2]
    i_re = re.compile(sys.argv[3])
    
    jj = sys.argv[4]
    j_re = re.compile(sys.argv[5])
    
    jsons = []
    for l in sys.stdin:
        jsons.append(l.rstrip('\n'))
    
    for i, j in zip(jsons[0::2], jsons[1::2]):
        cond1 = re.findall(i_re, getobj(i,ii))
        cond2 = re.findall(j_re, getobj(j,jj))
        if (mode == 'both' and (cond1 and cond2)) or \
            (mode == 'either' and (cond1 or cond2) ):
            print(i)
            print(j)
        elif mode == 'each':
            if cond1: print(i)
            if cond2: print(j)
        else:
            PREFIX = 'STDERR\t'
            print(PREFIX + i,file=sys.stderr)
            print(PREFIX + j,file=sys.stderr)

走行例

$ cat json.jsonlines | python3 jsonlineselect.py  both index._id '^1'  first '^jo.*'
{"index":{"_id":1}}
{"first":"johnny","last":"日本語gaudreau","goals":[9,27,1],"assists":[17,46,0],"gp":[26,82,1],"born":"1993/08/13"}
STDERR  {"index":{"_id":2}}
STDERR  {"first":"sean","last":"monohan","goals":[7,54,26],"assists":[11,26,13],"gp":[26,82,82],"born":"1994/10/12"}
STDERR  {"index":{"_id":3}}
STDERR  {"first":"jiri","last":"hudler","goals":[5,34,36],"assists":[11,62,42],"gp":[24,80,79],"born":"1984/01/04"}
STDERR  {"index":{"_id":4}}
STDERR  {"first":"micheal","last":"frolik","goals":[4,6,15],"assists":[8,23,15],"gp":[26,82,82],"born":"1988/02/17"}
STDERR  {"index":{"_id":5}}
STDERR  {"first":"sam","last":"bennett","goals":[5,0,0],"assists":[8,1,0],"gp":[26,1,0],"born":"1996/06/20"}
STDERR  {"index":{"_id":6}}
STDERR  {"first":"dennis","last":"wideman","goals":[0,26,15],"assists":[11,30,24],"gp":[26,81,82],"born":"1983/03/20"}
STDERR  {"index":{"_id":7}}
STDERR  {"first":"david","last":"jones","goals":[7,19,5],"assists":[3,17,4],"gp":[26,45,34],"born":"1984/08/10"}
STDERR  {"index":{"_id":8}}
STDERR  {"first":"tj","last":"brodie","goals":[2,14,7],"assists":[8,42,30],"gp":[26,82,82],"born":"1990/06/07"}
STDERR  {"index":{"_id":39}}
STDERR  {"first":"mark","last":"giordano","goals":[6,30,15],"assists":[3,30,24],"gp":[26,60,63],"born":"1983/10/03"}
STDERR  {"index":{"_id":10}}
STDERR  {"first":"mikael","last":"backlund","goals":[3,15,13],"assists":[6,24,18],"gp":[26,82,82],"born":"1989/03/17"}
{"index":{"_id":11}}
{"first":"joe","last":"colborne","goals":[3,18,13],"assists":[6,20,24],"gp":[26,67,82],"born":"1990/01/30"}

2. 命名 jsonlineselect_sort (ソート用のフィールド名を指定してその順序にバルクロードファイルを並べる)

JSON Linesのアクション行(action)とペアとなるドキュメント行(doc)をひとかたまりに並べ替えます。

import itertools
import jsonlineselect
import sys
import json

"""
    cat json.jsonlines | python3 thisapp.py a_d index._id first 
"""

sorttype = sys.argv[1] #action/doc/a_d/d_a
    
ii = sys.argv[2]
jj = sys.argv[3]
    
jsons = []
for l in sys.stdin:
    jsons.append(l.rstrip('\n'))

c = itertools.count(1)
records = []
for i, j in zip(jsons[0::2], jsons[1::2]):
    funcmap = {
        'action': lambda a,d: a,
        'doc': lambda a,d: d,
        'a_d': lambda a,d: a + '___' +  d,
        'd_a': lambda a,d: d + '___' +  a
    }
    a = jsonlineselect.getobj(i,ii)
    d = jsonlineselect.getobj(j,jj)
    records.append({'sortkeyval': funcmap[sorttype](a, d),'n': next(c),'i': i, 'j': j}) 
    
for r in sorted(records, key=lambda r: r['sortkeyval']):
    print(r['i'])
    print(r['j'])

走行例

$ cat json.jsonlines | python3 jsonlineselect_sort.py doc index._id  first
{"index":{"_id":7}}
{"first":"david","last":"jones","goals":[7,19,5],"assists":[3,17,4],"gp":[26,45,34],"born":"1984/08/10"}
{"index":{"_id":6}}
{"first":"dennis","last":"wideman","goals":[0,26,15],"assists":[11,30,24],"gp":[26,81,82],"born":"1983/03/20"}
{"index":{"_id":3}}
{"first":"jiri","last":"hudler","goals":[5,34,36],"assists":[11,62,42],"gp":[24,80,79],"born":"1984/01/04"}
{"index":{"_id":11}}
{"first":"joe","last":"colborne","goals":[3,18,13],"assists":[6,20,24],"gp":[26,67,82],"born":"1990/01/30"}
{"index":{"_id":1}}
{"first":"johnny","last":"日本語gaudreau","goals":[9,27,1],"assists":[17,46,0],"gp":[26,82,1],"born":"1993/08/13"}
{"index":{"_id":39}}
{"first":"mark","last":"giordano","goals":[6,30,15],"assists":[3,30,24],"gp":[26,60,63],"born":"1983/10/03"}
{"index":{"_id":4}}
{"first":"micheal","last":"frolik","goals":[4,6,15],"assists":[8,23,15],"gp":[26,82,82],"born":"1988/02/17"}
{"index":{"_id":10}}
{"first":"mikael","last":"backlund","goals":[3,15,13],"assists":[6,24,18],"gp":[26,82,82],"born":"1989/03/17"}
{"index":{"_id":5}}
{"first":"sam","last":"bennett","goals":[5,0,0],"assists":[8,1,0],"gp":[26,1,0],"born":"1996/06/20"}
{"index":{"_id":2}}
{"first":"sean","last":"monohan","goals":[7,54,26],"assists":[11,26,13],"gp":[26,82,82],"born":"1994/10/12"}
{"index":{"_id":8}}
{"first":"tj","last":"brodie","goals":[2,14,7],"assists":[8,42,30],"gp":[26,82,82],"born":"1990/06/07"}

3. 命名 jsonlineselect_fieldselect(各行のうち指定のフィールド名の値のみ抜き出します)

これは前2つと違いペアリングはさほど意識していません。

def select_fields(jsonstr, fields):
    import os
    import jsonlineselect
    import json
    wrk = {}
    for f in fields:
        tmp = {}
        if _val := jsonlineselect.getobj(jsonstr, f, force_str=False):
            tmp = _val
            for i in reversed(f.split('.')):
                _d = {}
                _d[i] = tmp
                tmp = _d
        wrk = dict(**wrk,**tmp) 

    sort_keys = False
    _jsk = os.environ.get('JSON_SORT_KEYS')
    if _jsk == 'True':
        sort_keys = True
    return json.dumps(wrk,ensure_ascii=False,sort_keys=sort_keys)

if __name__ == "__main__":
    """
    cat json.jsonlines | python3 thisapp.py index._id  first goals
    """
    import sys

    fields = [ i for i in sys.argv[1:]]
        
    jsons = []
    for l in sys.stdin:
        jsons.append(l.rstrip('\n'))
    
    for i in jsons:
        print(select_fields(i,fields))
        

走行例 (エラーチェックはしていないので、何かあればすぐ自爆します。引数の指定の仕方に注意が必要ですが、複数指定可能です。)

$ cat json.jsonlines | python3 jsonlineselect_fieldselect.py index._id  first goals
{"index": {"_id": 1}}
{"first": "johnny", "goals": [9, 27, 1]}
{"index": {"_id": 2}}
{"first": "sean", "goals": [7, 54, 26]}
{"index": {"_id": 3}}
{"first": "jiri", "goals": [5, 34, 36]}
{"index": {"_id": 4}}
{"first": "micheal", "goals": [4, 6, 15]}
{"index": {"_id": 5}}
{"first": "sam", "goals": [5, 0, 0]}
{"index": {"_id": 6}}
{"first": "dennis", "goals": [0, 26, 15]}
{"index": {"_id": 7}}
{"first": "david", "goals": [7, 19, 5]}
{"index": {"_id": 8}}
{"first": "tj", "goals": [2, 14, 7]}
{"index": {"_id": 39}}
{"first": "mark", "goals": [6, 30, 15]}
{"index": {"_id": 10}}
{"first": "mikael", "goals": [3, 15, 13]}
{"index": {"_id": 11}}
{"first": "joe", "goals": [3, 18, 13]}

標準入力を受け取って、標準出力に流すので、複数パイプすることもできます。

Elasticsearch のバルクロード用JSON Lines ファイルをselectする toy スクリプト

概要

Elasticsearchのバルクロードは次の形式なのですが、ふと必要にかられてこんな形の2行1ペアのJSON Linesファイルから、なんちゃってselectを行うトイプログラムを作成してみました。

PUT hockey/_bulk?refresh
{"index":{"_id":1}}
{"first":"johnny","last":"gaudreau","goals":[9,27,1],"assists":[17,46,0],"gp":[26,82,1],"born":"1993/08/13"}
レコード分繰り返し

↓バルクロードの形式の例の記載あり(以降の記事はリンク先のバルクロード例のJSONLinesファイルを「esjson.jsonlines」ファイルに保存している体で記載しています) www.elastic.co

実例(Python)

import sys
import json
import re

def getobj(dict,keystr): 
    wrk = dict
    for i in keystr.split('.'):
            wrk = wrk[i]
    return wrk

def my_print_2json(i,j,stderr=False):
    i_json = json.dumps(i,ensure_ascii=False)
    j_json = json.dumps(j,ensure_ascii=False)
    if stderr:
        print('STDERR\t' + i_json,file=sys.stderr)
        print('STDERR\t' + j_json,file=sys.stderr)
    else:
        print(i_json)
        print(j_json)

def my_print_json(i):
    i_json = json.dumps(i,ensure_ascii=False)
    print(i_json)

if __name__ == "__main__":
    
    mode = sys.argv[1] #both/either/each
    
    ii = sys.argv[2]
    i_re = re.compile(sys.argv[3])
    
    jj = sys.argv[4]
    j_re = re.compile(sys.argv[5])
    
    jsons = []
    for l in sys.stdin:
        jsons.append(json.loads(l))
    

    for i, j in zip(jsons[0::2], jsons[1::2]):
        cond1 = re.findall(i_re, str(getobj(i,ii)))
        cond2 = re.findall(j_re, str(getobj(j,jj)))
        if mode == 'both' and (cond1 and cond2):
            my_print_2json(i,j)
        elif mode == 'either' and (cond1 or cond2):
            my_print_2json(i,j)
        elif mode == 'each':
            if cond1: my_print_json(i)
            if cond2: my_print_json(j)
        else:
            my_print_2json(i,j,stderr=True)

こんな感じで起動〜出力が得られます。

foo$ cat esjson.jsonlines | python3 esjsonline_select.py both index._id '^1'  first '^jo'
{"index": {"_id": 1}}
{"first": "johnny", "last": "日本語gaudreau", "goals": [9, 27, 1], "assists": [17, 46, 0], "gp": [26, 82, 1], "born": "1993/08/13"}
STDERR  {"index": {"_id": 2}}
STDERR  {"first": "sean", "last": "monohan", "goals": [7, 54, 26], "assists": [11, 26, 13], "gp": [26, 82, 82], "born": "1994/10/12"}
STDERR  {"index": {"_id": 3}}
STDERR  {"first": "jiri", "last": "hudler", "goals": [5, 34, 36], "assists": [11, 62, 42], "gp": [24, 80, 79], "born": "1984/01/04"}
STDERR  {"index": {"_id": 4}}
STDERR  {"first": "micheal", "last": "frolik", "goals": [4, 6, 15], "assists": [8, 23, 15], "gp": [26, 82, 82], "born": "1988/02/17"}
STDERR  {"index": {"_id": 5}}
STDERR  {"first": "sam", "last": "bennett", "goals": [5, 0, 0], "assists": [8, 1, 0], "gp": [26, 1, 0], "born": "1996/06/20"}
STDERR  {"index": {"_id": 6}}
STDERR  {"first": "dennis", "last": "wideman", "goals": [0, 26, 15], "assists": [11, 30, 24], "gp": [26, 81, 82], "born": "1983/03/20"}
STDERR  {"index": {"_id": 7}}
STDERR  {"first": "david", "last": "jones", "goals": [7, 19, 5], "assists": [3, 17, 4], "gp": [26, 45, 34], "born": "1984/08/10"}
STDERR  {"index": {"_id": 8}}
STDERR  {"first": "tj", "last": "brodie", "goals": [2, 14, 7], "assists": [8, 42, 30], "gp": [26, 82, 82], "born": "1990/06/07"}
STDERR  {"index": {"_id": 39}}
STDERR  {"first": "mark", "last": "giordano", "goals": [6, 30, 15], "assists": [3, 30, 24], "gp": [26, 60, 63], "born": "1983/10/03"}
STDERR  {"index": {"_id": 10}}
STDERR  {"first": "mikael", "last": "backlund", "goals": [3, 15, 13], "assists": [6, 24, 18], "gp": [26, 82, 82], "born": "1989/03/17"}
{"index": {"_id": 11}}
{"first": "joe", "last": "colborne", "goals": [3, 18, 13], "assists": [6, 20, 24], "gp": [26, 67, 82], "born": "1990/01/30"}

第1引数 → both/either/each 第2引数と第3引数、 および 第4引数と第5引数 → それぞれ前者でJSONのフィールド名を「A.B.C」形式で指定して、その値がマッチすべきPythonのreモジュールの正規表現を指定します。  前の組が奇数行、後ろの組がその次行の偶数行のJSON に対するselect演算相当になります。

ここで、bothはカップリングの行の両方が条件に該当した場合にカップルの2つの行を出力、eitherはどちらかの行がマッチした場合にそのカップルの2つの行を出力、eachは単に1行ずつのマッチングとなっています。

おわりに

AWS Athenaやjqコマンドなども使えない淋しい環境においては、意外に使える(応用がきく)かもしれず、思わず記事にしてしまいました。

2行一組で、diffというかcompare、sortなども便利かも...そのうちメモっておこう(独り言)

Pythonで簡易HTTP静的ファイルサーバ -- 細工用にWSGIをひっかける

概要

この記事は技術的チャレンジ...というよりは、他の用途の都合、PythonWSGIのライブラリのさわりを目的外(?)使用した例です。

という意味で、WSGIの説明などではありませんのでご了承ください。

内容としては、

WSGIの仕組みで、

ワンライナーではないものの、

ワンライナー程度のステップ数でHTTP静的HTML簡易ホスティングサーバを実現する

というものになっています。

前置き

コンフィグファイル等も必要なくその分やれることも限られていて良いので、(ちょっとした静的HTML一式の表示確認用などに)コマンドラインから手軽に起動できるHTTPサーバはないんかね。

昔はともかく今だったらプログラミング言語の標準モジュールの範囲でも実現できるやつがあるあるんじゃないか、ないはずはないと思っていたら、... ありました。

例えばPythonだったら、

python -m http.server 8090

などとすると

起動したカレントディレクトリをドキュメントルートにして、静的コンテンツのホスティングが可能です。

http://localhost:8090/foo/bar.html

などでアクセスしてみてください。

Rubyだとどうでしょうか。

もちろんありました。

ruby -run -e httpd . -p 8090

有名でかなり昔から対応しているようです。

おもしろそうなので、他にもないかと調べて記事にしようかと思ったものの、ググったらすばらしいまとめがありましたので、そちらにリンクさせていただくことにします。

qiita.com

今だとDockerとかもあるでしょう。また、WAFのインストールのハードルも下がっているのですが、ワンライナーでいけるならその分本来の目的に集中できる/いくつか同時にサーバを起動して見比べなどもできるので、このようなワンライナーはありがたいですね。

あらまし(ワンライナーよりタイプ量が増えてもよいが標準ライブライの応用程度でいざとなれば独自の細工ができるHTTP静的ファイルサーバを気軽に起動したい)

ワンライナーでここまでいけるとすると少しだけ欲がでてきます。

というのも、私の経験ではボチボチある話なのですが、

User-Agentなどを元に、オリジンの静的HTMLのファイルを切り替えてやる...ということができると、デザイナーさんが作成したスマホ向けとPC向けの紙芝居HTMLのデモをインターネットに繋がなくても実施できる

あるいは

レスポンスのhtml中の特定のタグを書き換えてやる(もっとも端的なものは画像などをCDNサーバに配置のものにさしかえてやる)など、オリジンは静的HTMLだが少し細工をしたい

といったものです*1

どうにかこれらに手間をかけずに対応できないでしょうか。できればあまり凝った環境などを用意せずに、です。

残念ながらこれらは静的ホスティングの狭い方の守備範囲よりはちょいと広そうなので、先のワンライナーだけではだめそうです。

最終プロダクトでは静的どころかもっといろんな仕組みや要件を取り込んで念入りに実現することになるでしょうが、今の目的は、静的ホスティングでイケるかもう少し何か細工が必要かの見極めという前段の話です。

よって、WAF(略していましたが、Web Application Framework)を使うほどの手間をかけたくない、apacheやnginxを真面目に設定するのも今の時点では割りに合わないです。

方針:WSGIをつまみ食いする

ムシの良い話だとは思っていますが、何か都合の良い頃合いの良いしかけはないだろうか。

静的ホスティング以上、WAFなどの動的処理未満というところですので、プログラミング言語にミニチュアのWAF風のものがあれば良いのですが...

幸いPythonには、Python製のWAFを実現する場合に、従うことが推奨されている約束事(WSGI; Web Server Gateway Interface)というものがあるようです。

FlaskなどもWSGIにしたがっているとのこと。

また、この約束事に従うと、HTTP周辺のハンドリングは標準ライブラリにまかせることができそうですし、現にwsgiref.simple_server というものが使えそうです。

docs.python.org

静的ファイルホスティング on WSGIベースのソースコード

早速ですが、Pythonソースコード例を以下に示します。これを適当なファイル名で保存して、コマンドラインで起動すると、8090ポートで、起動ディレクトリをドキュメントルートにした静的ファイルホスティングのサーバが立ち上がります。

from wsgiref.simple_server import make_server
from collections import defaultdict
import pathlib

CONTENT_TYPE = defaultdict(lambda: 'application/octed-stream')
CONTENT_TYPE.update({
        '.html': 'text/html; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.js': 'text/javascript', '.json': 'application/json',
        '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif',
        '.css':'text/css'
    }
)

def endpoint(environ, start_response):
    p = environ['PATH_INFO']
    filepath = '.' + ( p + 'index.html'  if p.endswith('/') else p )
    headers = [('Content-type', CONTENT_TYPE[pathlib.Path(filepath).suffix])]
    with open(filepath,mode='br') as f:
        data = f.read()

    start_response('200 OK', headers)
    return [bytes(data)]

make_server('localhost', 8090, app=endpoint).serve_forever()

'PATH_INFO'というキーで、アクセスされたURIのパス情報が取得できるので、その後、ディレクトリ上のファイルを読み込み、それをレスポンスデータに戻しています。

今回は特に何もしていませんが、ファイル名を解析したり、読み込んだファイルの内容(上記でいうと変数dataに格納)を改変するなどすれば、オリジンの静的ファイルを元にいくつかアドホックなトリックを組み込むことができます。

上記の「environ」という変数はdict型の変数で、次のような情報が取得できます。

なお、環境によっては他にも取得できる項目がありますが、ここではいつものアレねというものを中心にあげています。

SERVER_NAME 1.0.0.127.in-addr.arpa
SERVER_PORT 8090
REMOTE_HOST 
CONTENT_LENGTH 
SERVER_PROTOCOL HTTP/1.1
SERVER_SOFTWARE WSGIServer/0.2
REQUEST_METHOD GET
PATH_INFO /foo/bar.html
QUERY_STRING bar=aaa&boo=bbb
REMOTE_ADDR 127.0.0.1
CONTENT_TYPE text/plain
HTTP_HOST localhost:8000
HTTP_CONNECTION keep-alive
HTTP_CACHE_CONTROL max-age=0
HTTP_UPGRADE_INSECURE_REQUESTS 1
HTTP_USER_AGENT Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.131 Safari/537.36
HTTP_SEC_FETCH_USER ?1
HTTP_ACCEPT text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
HTTP_SEC_FETCH_SITE none
HTTP_SEC_FETCH_MODE navigate
HTTP_ACCEPT_ENCODING gzip, deflate, br
HTTP_ACCEPT_LANGUAGE ja,en-US;q=0.9,en;q=0.8
HTTP_COOKIE __gads=ID=22edc85a3f0fd9be:T=1581602476:S=ALNI_MaJxNlUyXY_9qzIWOiLSbzE4_3qIA
wsgi.url_scheme http

このプログラムは、自身で卑下するわけではないですが、WSGI準拠と呼べるのかどうかというレベルです。

make_serverをimportしているだけで、WSGI準拠とは多分言えないです。その点ご注意ください。

また、エラーハンドリングはほとんどされておらず、当たり前のように常にステータス200を返すという実装です。

セキュリティ...は微塵も考慮されていないですね。ただし、今回はこれでいいのです。

http.server版

最初に紹介したPythonワンライナー版ですと、http.serverというモジュールを使いました。

http.serverは公式ドキュメントにも書いてあるとおり、プロダクトに利用する類のものと公式が言う類のものなので、WSGIよりやれることは狭まるようですが、今回程度であればあまり変わらないかもしれません。

http.serverのBaseHTTPRequestHandlerを継承して(特にdoGETをオーバーライドすると)、先のWSGI版の例とおおよそ同じ動作になる例が作れるのでこれを示します。

from http.server import BaseHTTPRequestHandler, HTTPServer
from collections import defaultdict
import pathlib

CONTENT_TYPE = defaultdict(lambda: 'application/octed-stream')
CONTENT_TYPE.update({
        '.html': 'text/html; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.js': 'text/javascript', '.json': 'application/json',
        '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif',
        '.css':'text/css'
    }
)

class MyHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        p = self.path
        self.send_response(200)
        filepath = '.' + (p + 'index.html' if p.endswith('/') else p)
        with open(filepath,mode='br') as f:
            data = f.read()
        self.send_header('Content-type', CONTENT_TYPE[pathlib.Path(filepath).suffix])
        self.end_headers()
        self.wfile.write(bytes(data))

with HTTPServer(('localhost',8090), MyHandler) as srv:
    srv.serve_forever()

なお、selfをdirしたところ次のような値が戻ってきました、直接httpヘッダーの項目をひとつずつ取得することはできなさそうですが、requestlineなどからどうにかできるのでしょうか(未確認)。

['MessageClass', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'address_string', 'client_address', 'close_connection', 'command', 'connection', 'date_time_string', 'default_request_version', 'disable_nagle_algorithm', 'do_GET', 'end_headers', 'error_content_type', 'error_message_format', 'finish', 'flush_headers', 'handle', 'handle_expect_100', 'handle_one_request', 'headers', 'log_date_time_string', 'log_error', 'log_message', 'log_request', 'monthname', 'parse_request', 'path', 'protocol_version', 'raw_requestline', 'rbufsize', 'request', 'request_version', 'requestline', 'responses', 'rfile', 'send_error', 'send_header', 'send_response', 'send_response_only', 'server', 'server_version', 'setup', 'sys_version', 'timeout', 'version_string', 'wbufsize', 'weekdayname', 'wfile']

docs.python.org

node.js版

同じ目的でのnode.js版です。mozillaの公式に記載がありました。node.jsが流行りだしたころによく見ましたね。

developer.mozilla.org

これの上記Pythonと同じ粗々度の例を作成しようと思いましたが、たまたまググってて模範的な例を見つけたので、こちらのリンクをご紹介することにしました。

*1:CMSのPoCってところでしょうか。

Python itertools.groupby、itertools全般(accumulate他)、collectionsのちょっとつまみ食い

この記事について

itdepends.hateblo.jp

Python/Pandasのgroupbyについては、上記の前の記事で大口(?)を叩いていしまいましたが、そもそも標準ライブラリのitertoolsにgroupbyというメソッドがあるのでこれを機会に入門してみました。

itertoolsのgroupbyおよび、その他のitertools、collectionsの関数などでおもしろそうだと思ったものをつまみ食いしています。

公式へのリファレンスなど

◆itertools.groupby docs.python.org

◆itertools docs.python.org

◆collections docs.python.org

itertools.groupbyの定義

itertools.groupby(iterable, key=None)

keyのところは関数が引数に取れるようです。sortedなどと同様ですね。iterableの一つずつの要素に、keyで指定の関数で値を取り出して、その値を元にグループ化されるということでしょう。

試してみます。

itertools.groupbyの例

import itertools as it

a = list('ababaaacdeb')

# groupby(key引数指定無し)
print([[k, len(list(g))] for k, g in it.groupby(a)])
#[['a', 1], ['b', 1], ['a', 1], ['b', 1], ['a', 3], ['c', 1], ['d', 1], ['e', 1], ['b', 1]]

listの要素そのもの(a,b,..)単位でグループ化されて件数が得られるようです... というところですが、aの項が3つに分かれていますね。

公式の記事にあるとおり、Unixのuniqコマンドと同様にインプットの並びの塊でグループ化されるようです。

よって、並びの単位で同じものが続く件数を得たい場合は、この使い方になりますし、SQLのGROUPBYライクな動作をさせたい場合は、インプットをsortedなどでソートしてやることになります。

# groupby ソートする

a = list('ababaaacdeb')

print([[k, len(list(g))] for k, g in it.groupby(sorted(a))])
#[['a', 5], ['b', 3], ['c', 1], ['d', 1], ['e', 1]]

# a,b,c,d,eのそれぞれの件数が得られた!!

ちなみに上記の相当の結果を得たいだけなら、collections.Counterの方がより直接的です。

# collections.Counter -> このケースだと、ソートしたgroupbyと同等
import collections as cl
print(cl.Counter(a))
#Counter({'a': 5, 'b': 3, 'c': 1, 'd': 1, 'e': 1})

続いてgroupbyに関数を渡してみます。

# groupby再び:  グループ化の条件としてkeyにlambdaを渡すことができる
a = ['yaa', 'xab', 'xxx', 'xbb', 'zcc', 'ycc']
igen = it.groupby(sorted(a, key=lambda x: x[0]), lambda x: x[0])
print([[k, len(list(g))] for k, g in igen])
#[['x', 3], ['y', 2], ['z', 1]]


# 上記のように数えるだけなら、collection.Counterでも同等
print(cl.Counter(map(lambda x:x[0],a)))
#Counter({'x': 3, 'y': 2, 'z': 1})

「lambda x: x[0]」渡したことにより、インプットのリストの各要素の最初の1文字(x,y,z)でグループ化されましたね。

その他の関数例

chain

これは例を見た方が早いでしょう。

私は、似た動作をするものとしては、itertools.flattenの方をよく使うかな。

In [168]: list(it.chain('123','456'))                                                                                                          
Out[168]: ['1', '2', '3', '4', '5', '6']

In [169]: list(it.chain('abc','def','ghij'))                                                                                                   
Out[169]: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

In [170]: list(it.chain([[1],[2]],['aa','bb']))                                                                                                
Out[170]: [[1], [2], 'aa', 'bb']

何かと嬉しいジェネレーター(count、cycle) および compress

countとcycleです。

おおよそ名前のイメージのとおりの挙動です。

cnt += 1

のようなコードで可読性が低いと感じるようでしたら、そのような記法からはおさらばできそうです。

# count
g = it.count(10)
print(next(g))
print(next(g))

# cycle
g = it.cycle('ABCD')
print(next(g))
print(next(g))
print(next(g))
print(next(g))
print(next(g))

g = it.cycle(list('ABCD'))
print(next(g))
print(next(g))
print(next(g))
print(next(g))
print(next(g))

続いて compress です。

# compress ビットマスクのような印象
print(list(it.compress('ABCDE',[1,0,0,1,0]))) 
# A,D

product、permutations、combinations...といった関数もありますが、ここではproductへのリンクに留めておきます。

accumulateなど(filterfalse、takewhile、dropwhileなど兄弟?シリーズ)

accumulate、filterfalse、takewhile、dropwhile、accumlateらはなかなか面白いです。

まず、accumulateの軽めの例。

# accumulate
print(list(it.accumulate([1, 2, 3, 4, 5, 6])))
# 1,3,6,10,15,21

これらは引数が似ていてとっさにどれがどう使えるか条件反射的にわかるようにということで雑な図にしてみました。

f:id:azotar:20200212234741p:plain

In [330]: import itertools as it 
     ...: data = [1, 2, 3, 4, 5, 6, 5, 4, 3, 10] 
     ...: list(it.dropwhile(lambda x: x < 5, data)) 
     ...:  
     ...:  
     ...:  
     ...:                                                                                                                                               
Out[330]: [5, 6, 5, 4, 3, 10]

accumulateも他のもの同様に関数を引数に取れるのですが、map/reduceのreduce風というか、複数引数をとることができますので、細工の余地がありそうです。

# accumulateの例 再び

In [163]: import itertools as it 

In [164]: data                                                                                                                                 
Out[164]: [3, 4, 6, 2, 1, 9, 0, 7, 5, 8]

In [161]: list(it.accumulate(data))                                                                                                            
Out[161]: [3, 7, 13, 15, 16, 25, 25, 32, 37, 45]

In [162]: list(it.accumulate(data,lambda x,y: x + y ))                                                                                         
Out[162]: [3, 7, 13, 15, 16, 25, 25, 32, 37, 45]

In [165]: list(it.accumulate(data,lambda x,y: x + str(y) ,initial='a'))                                                                        
Out[165]: 
['a',
 'a3',
 'a34',
 'a346',
 'a3462',
 'a34621',
 'a346219',
 'a3462190',
 'a34621907',
 'a346219075',
 'a3462190758']

In [167]: list(it.accumulate(data,lambda x,y: x + ( str(y) if y < 7 else '') ,initial='a'))                                                    
Out[167]: 
['a',
 'a3',
 'a34',
 'a346',
 'a3462',
 'a34621',
 'a34621',
 'a346210',
 'a346210',
 'a3462105',
 'a3462105']

accumlateを使って、カナとそのカナの行のローマ字の対応表を生成

accumlateを使って、カナとそのカナの行のローマ字の対応表を生成させて見ました。

◆accumulate使わず

import itertools as it

kanaall = [chr(c) for c in range(ord('ァ'), ord('ン') + 1)]
kana = list('ァカサタナハマャラヮン')
roma = 'a,ka,sa,ta,na,ha,ma,ya,ra,wa'.split(',')
kanaxroma = {k: r for k, r in zip(kana, roma)}


kashira_roma = {}
for k in kanaall:
    if kanaxroma.get(k) :
        kashira_roma[k] = tmp = kanaxroma[k]
    else: 
        kashira_roma[k] = tmp 

print(kashira_roma)

◆accumulateを使う

import itertools as it

kanaall = [chr(c) for c in range(ord('ァ'), ord('ン') + 1)]
kana = list('ァカサタナハマャラヮン')
roma = 'a,ka,sa,ta,na,ha,ma,ya,ra,wa'.split(',')
kanaxroma = {k: r for k, r in zip(kana, roma)}
_roma = it.accumulate(kanaall, lambda x, y: (y in kanaxroma) and kanaxroma[y] or x, initial='')
next(_roma) # 1要素目はダミーなのでスキップする
print({k:v for k,v in zip(kanaall,_roma)})

◆出力結果

どちらも下記の結果が得られます。

私の実力の都合でそれほど面白い事例にはなりませんでしたが...

{'ァ': 'a', 'ア': 'a', 'ィ': 'a', 'イ': 'a', 'ゥ': 'a', 'ウ': 'a', 'ェ': 'a', 'エ': 'a', 'ォ': 'a', 'オ': 'a', 'カ': 'ka', 'ガ': 'ka', 'キ': 'ka', 'ギ': 'ka', 'ク': 'ka', 'グ': 'ka', 'ケ': 'ka', 'ゲ': 'ka', 'コ': 'ka', 'ゴ': 'ka', 'サ': 'sa', 'ザ': 'sa', 'シ': 'sa', 'ジ': 'sa', 'ス': 'sa', 'ズ': 'sa', 'セ': 'sa', 'ゼ': 'sa', 'ソ': 'sa', 'ゾ': 'sa', 'タ': 'ta', 'ダ': 'ta', 'チ': 'ta', 'ヂ': 'ta', 'ッ': 'ta', 'ツ': 'ta', 'ヅ': 'ta', 'テ': 'ta', 'デ': 'ta', 'ト': 'ta', 'ド': 'ta', 'ナ': 'na', 'ニ': 'na', 'ヌ': 'na', 'ネ': 'na', 'ノ': 'na', 'ハ': 'ha', 'バ': 'ha', 'パ': 'ha', 'ヒ': 'ha', 'ビ': 'ha', 'ピ': 'ha', 'フ': 'ha', 'ブ': 'ha', 'プ': 'ha', 'ヘ': 'ha', 'ベ': 'ha', 'ペ': 'ha', 'ホ': 'ha', 'ボ': 'ha', 'ポ': 'ha', 'マ': 'ma', 'ミ': 'ma', 'ム': 'ma', 'メ': 'ma', 'モ': 'ma', 'ャ': 'ya', 'ヤ': 'ya', 'ュ': 'ya', 'ユ': 'ya', 'ョ': 'ya', 'ヨ': 'ya', 'ラ': 'ra', 'リ': 'ra', 'ル': 'ra', 'レ': 'ra', 'ロ': 'ra', 'ヮ': 'wa', 'ワ': 'wa', 'ヰ': 'wa', 'ヱ': 'wa', 'ヲ': 'wa', 'ン': 'wa'}

最後ですが、この流れでやや唐突ですが、collectionsの nametupleです。

私は他の言語でいうenumを比較的好むので、nametupleはわりかし好きな関数です。

In [333]: from collections import namedtuple 
     ...:  
     ...: Rect = namedtuple('Rect', ('x', 'y', 'width', 'height', )) 
     ...: rect = Rect(1, 2, 3, 4)                                                                                                                       

In [334]: rect.height                                                                                                                                   
Out[334]: 4

In [335]:                                                                           

さいごに

公式の下記のレシピが、itertools自体およびちょっとしたPythonicなコードとして非常に参考になると思いました。

docs.python.org

Python/Pandas演習(dictでグループ化相当)

Python/Pandasにはgroupbyの機能があるのですが、少しトリッキーなグループ化を考えてみたメモです。

複雑な名寄せ・グループ化のニーズ

何かの集計や名寄せの際に、複数のキーやキー自体がネストされた構造化データ相当のデータで名寄せしたいということはありませんか。

まあ、いい感じで管理されているデータについてはそのようなニーズはそれほどないのかもしれませんが、私の身近な例ではぼちぼち発生しているような気がします。

こんなデータ↓を

名前
0 山田 1 100
1 山田 1 100
2 山田 1 100
3 山田 4 60
4 山田 2 33
5 佐藤 6 9
6 田中 4 209

↓こんな感じで名寄せ(グループ化)したいです。

jsons
0 {"値": 100, "名前": "山田", "月": 1} [100, 100, 100]
1 {"値": 209, "名前": "田中", "月": 4} [209]
2 {"値": 33, "名前": "山田", "月": 2} [33]
3 {"値": 60, "名前": "山田", "月": 4} [60]
4 {"値": 9, "名前": "佐藤", "月": 6} [9]

データ例がリアルでないですね。またこの程度であれば、もっと正攻法でやった方が何かと良いというところがあるでしょう。

... と弱気になっておりますが、後には引けないので続けます。

ポイント

後述しますが、Pandasのgroupbyはdict型のカラムをグループキーにしたgroupbyはエラーになってしまうようです。

よって、ひとつの方法としては、このようなグループ化のキーにしたい項目自体をJSON文字列にしてしまう案があります。

Pythonもしかりですが、最近のプログラミング言語ではネスト型のデータについてJSON文字列との相互変換の標準ライブラリが充実しています。

オーバーヘッドや処理性能も気になりますが、あとでいろいろできることを期待して割り切って文字列にしてグループ化するアプローチはありだと思います。

なお、JSON文字列自体は、配列以外は順序に意味がないので、同じ意味のデータが同じと確実に見なされるようにJSON文字列化の際は、プロパティ名が昇順になるような編集になるようにするというのが隠れたポイントです。

コーディングの例

import pandas as pd
import io
import json

# 入力データ
right = pd.read_csv(io.StringIO(
"""名前,月,値
山田,1,100 
山田,1,100 
山田,1,100 
山田,4,60 
山田,2,33 
佐藤,6,9
田中,4,209""" ))


right['jsons'] =  right.apply(lambda s:  json.dumps({k:v for k,v in s.items()},sort_keys=True,ensure_ascii=False),axis=1)

x = right.groupby('jsons')['値'].apply(list).reset_index()

print(x.to_markdown(), '\n\n')

json.dumps()でdictを文字列にしています。

sort_keys=Trueでプロパティの出力順をがプロパティ名の昇順になるように指定しています。

ここでは、同じグループ内の「'値'」フィールドを「list」関数でリストにしたデータを取得しています。

結果

jsons
0 {"値": 100, "名前": "山田", "月": 1} [100, 100, 100]
1 {"値": 209, "名前": "田中", "月": 4} [209]
2 {"値": 33, "名前": "山田", "月": 2} [33]
3 {"値": 60, "名前": "山田", "月": 4} [60]
4 {"値": 9, "名前": "佐藤", "月": 6} [9]

ちょっと例がアレですが、[100,100,100]のようにまとめられています。

その他の例

groupbyのGrouperに関数を指定

文字列にすると、DataFrame.set_indexでDataFrameのインデックスに設定できます。

groupbyはグループ化の条件に関数を指定できますが、これは対象のDataFrameのインデックスに対して作用するようです*1

逆に言うと、生成した文字列化jsonデータをDataFrameのインデックスに設定することで、この値をフックしたGrouperに関数が使えるようになります。

例1

# jsonsフィールドをインデックスに設定する
x_ = right.set_index('jsons')

print(x_.to_markdown(), '\n\n') 

# これはあまり意味のない例だが、インデックスの格納値の文字列桁数ごとにグルーピングする。
x = x_.groupby(len)['値'].apply(list).reset_index()

print(x.to_markdown(), '\n\n')

↓ (出力例)

index
0 28 [9]
1 29 [60, 33]
2 30 [100, 100, 100, 209]

len( 【{"値": 100, "名前": "山田", "月": 1}の文字列】) → 長さ30

例2

また、止むを得ずdict/jsonを文字列にしましたが、関数の中でjsonをdictに戻してやってその上でのグループ化を行うことができるので、(やろうと思えばですが)関数を用いてdictの構造を読み解いたようなグループ化も可能です。

# 最初の10文字でグループ化(この例はこの方式のメリットを体現していないですが...)
x = x_.groupby(lambda idx: idx[0:10])['値'].apply(list).reset_index()

print(x.to_markdown(), '\n\n')

# 名前フィールドを引っ張り出してグループ化(この例はこの方式のメリットを体現していないですが...)
x = x_.groupby(lambda idx: json.loads(idx)['名前'] )['値'].apply(list).reset_index()

print(x.to_markdown(), '\n\n')

最初の10文字でグループ化

index
0 {"値": 100, [100, 100, 100]
1 {"値": 209, [209]
2 {"値": 33, [33]
3 {"値": 60, [60]
4 {"値": 9, " [9]

名前でグループ化((この例であれば、最初から x_.groupby('名前')で良い))

index
0 佐藤 [9]
1 山田 [100, 100, 100, 60, 33]
2 田中 [209]

以上です。

補足

dictが入っているフィールドでDataFrame.groupby()を行うと、groupbyの時点ではエラーにはならないのですが、その後何か演算しようとするとエラーになります。

ひょっとすると何かオプションがあるのかもしれませんが、公式ドキュメントではこれといって見当たりませんでしたので、この記事をおこしました。

In [324]: import pandas as pd 
     ...: import io 
     ...: import json 
     ...: import numpy as np 
     ...:  
     ...:  
     ...: right = pd.read_csv(io.StringIO( 
     ...: """名前,月,値 
     ...: 山田,1,100  
     ...: 山田,1,100  
     ...: 山田,1,100  
     ...: 山田,4,60  
     ...: 山田,2,33  
     ...: 佐藤,6,9 
     ...: 田中,4,209""" ))                                                                                                                              

In [325]: right['jsons'] =  right.apply(lambda s:  {k:v for k,v in s.items()},axis=1)                                                                   

In [326]: right.groupby('jsons').groups                                                                                                                 
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-326-ecb97710e4c7> in <module>
----> 1 right.groupby('jsons').groups
(-------------------中略------------------)

TypeError: unhashable type: 'dict'

In [327]:                                                                                                                                               

参考リンク

pandas.pydata.org

docs.python.org

*1:公式リファレンスの読み解きが足りないかもしれませんが明示的な記述は見当たりませんでした。

1対Nのデータを結合してネストオブジェクトのJSON Lines出力のイディオム(Python/ Pandas) my 手グセ紹介

たまたま自分のまわりだけかもしれませんが、次項に示した例のように、1対Nのデータを結合してネストオブジェクトのJSON Lines出力をざっくりで良い精度でやってしまいたいという例にしばしば遭遇します。

ブームの時に手グセになっていたり、その案件でガチで必要ならそれなりの関数にしてしまうので良いのです。

ただ、大抵はそこまでいかないのと、忘れたころに、あれ?そんなに難しくないんだけど、どんな感じだったけ、めんどくさいなぁとなって、自分の書き捨てのコードなど頼ることになります。

おそらく程度としては中途半端なので、世のイディオムやTIPSにはあがってこないのと、ググラビリティが低そうなので、やりたいことのイメージ残しを含めて、この記事でメモってみます。

やりたいこと概要

こんなデータ(LEFT)と

名前 所属
0 山田 営業1
1 佐藤 営業2
2 田中 営業2

こんなデータ(RIGHT)があって、

名前
0 山田 1 100
1 山田 4 60
2 山田 2 33
3 佐藤 6 9
4 田中 4 209

こんなデータ(出力)を

名前 所属 関連データ
0 山田 営業1 [{'名前': '山田', '月': 1, '値': 100}, {'名前': '山田', '月': 2, '値': 33}, {'名前': '山田', '月': 4, '値': 60}]
1 佐藤 営業2 [{'名前': '佐藤', '月': 6, '値': 9}]
2 田中 営業2 [{'名前': '田中', '月': 4, '値': 209}]

出力したい。

なお、リストにしている部分は、「月」順にソートしておきたい。

Pythonので例

import pandas as pd
import io

debug = False

left = pd.read_csv(io.StringIO(
"""名前,所属
山田,営業1
佐藤,営業2
田中,営業2"""
))

if debug:
    print(left.to_markdown(),'\n\n')                                                                                       

right = pd.read_csv(io.StringIO(
"""名前,月,値
山田,1,100 
山田,4,60 
山田,2,33 
佐藤,6,9
田中,4,209""" ))
if debug:
    print(right.to_markdown(),'\n\n')                                                                                                                                       

keycols = ['名前', '月']
on =  ['名前']
grpcols = on
sortcol = '月'
dictcol = '関連データ'
_renamecols = {i: j for i, j in zip(range(len(keycols)), keycols)}
関連データカラム名称変更 = {len(keycols): dictcol}

_func = lambda s,keycols: pd.Series([s[k] for k in keycols] + [ {k:v for k,v in s.items()}])

rightdf = right.apply(_func, axis=1, keycols=keycols).rename(columns=_renamecols).rename(columns=関連データカラム名称変更)

rightdf_gr = rightdf.groupby(grpcols)[dictcol]

_listsorted = lambda s: sorted(list(s), key=lambda el: el[sortcol])

出力 = pd.merge(left,rightdf_gr.apply(_listsorted).reset_index(),on=on)

if debug:
    print(出力.to_markdown(),'\n\n')

print(出力.to_json(orient='records',lines=True,force_ascii=False))

自分の中でのポイント

  1. _funcのあたりで、全てのカラムを取り込んだdictを生成する。ここでは元のDataFrameをさわりたくなかったので、rightdfに代入している。
  2. 一度rightdfのようなDataFrameを作成してから、それをgroupbyする。*1
  3. groupbyしたのち、dictcolで指定のカラムをグループ単位に配列にする。

補足

  1. そもそも pandasを使用。今回の用途であれば、collectionsあたりを使いこなした方がスマートかもしれない。
  2. 利用 pandas のバージョンは 1.0.1。この記事のやりたいことのためには本来必須ではないが、pandasバージョンアップ記念に、ver 1で導入された(らしい) to_markdown() を使ってみている。
  3. 見てのとおりpandasが入っていれば動作するレベルだが、to_markdown()で、tabulateが古いと怒られるので、pipでアップデートが必要かもしれない。

pandas.pydata.org

pandas.pydata.org

docs.python.org

*1:groupbyのグループ化に「'名前','月'」を使うつもりだったが、ここでは「名前」だけにしたので、事例としてはkeycolsを定義した意味が薄れているが書き直すのも手間なのでこのままとしている。

Pythonオレオレ相対パス/ルート相対パスの小品

はじめに

このブログは総じて自分メモなのですが、その中でも次の過去記事の応用(?)として、自分の手に馴染むルート相対/相対パス周りのオレオレミニライブラリを作成したのでそのメモです。

itdepends.hateblo.jp

itdepends.hateblo.jp

なぜこの記事なのか

ほぼ車輪の再発明なんですが、というか再発明でさえなく/ライブラリと呼べるものでもないというのが実体なんですが、実は再発明というよりは、この類のラッパーを作って、ミニDSLっぽくしたいことがあるんですよね。

つまり、プログラムの純粋な共通化やその他のプラクティスの方向というよりは、当時自分がやりたかった案件・ドメインを切り取ったような・それを体現したような関数やクラスを残しておきたいというところ。

なお、隠れたウリとして、次のPythonのメタな処理の関数を使って、走行確認テスト((テストコードとは呼べないな))をタイプ量少なめで実装してみています。

  • inspect.getmembers によるクラスのメソッド名一覧取得
  • inspect.signature による、あるメソッドのシグネチャ確認
  • getattr による、メソッド名文字列を与えて関数実行

inspect.getmembers と inspect.signature docs.python.org docs.python.org

getattr ビルトイン関数( getattr(x,'foobar')は x.foobarと等価です。というのが全てかも) docs.python.org

プログラムと実行結果

from urllib.parse import urlparse
from urllib.parse import urljoin
import os.path
import os
import re
import sys

class ResourcePath:
    def __init__(self,address):
        _a = re.sub('/index.html$','/',address)
        _a = re.sub('/index.htm$','/',address)
        self.url = _a
        self.IMG = 'jpg/jpeg/JPG/JPEG/png/PNG/gif/GIF/pdf/PDF'.split('/')
        self.CSS = 'css'.split('/')
        self.JS = 'js/json'.split('/')

    def __str__(self):
        return self.url        

    # 部分値の取得 ------------

    def scheme(self):
        return urlparse(self.url).scheme

    def netloc(self):
        return urlparse(self.url).netloc

    def dirname(self):
        return os.path.dirname(urlparse(self.url).path)

    def just_dirname(self):
        return self.dirname().split('/')[-1]

    def basename(self):
        return os.path.basename(urlparse(self.url).path)

    def extension(self):
        work = self.basename().split('.')
        return work[-1] if len(work) > 1 else ''

    # ルート相対パス関連の個別関数 ------------

    def ルート相対パス取得可能(self):
        if self.is相対パス():
            return False
        return True

    def ルート相対パス(self): 
        try:
            if self.is相対パス(): 
                raise ValueError
            return urlparse(self.url).path
        except:
            print('ERROR:相対パスに対してルート相対パス情報取得',sys.stderr)
            sys.exit()

    def set_cwd(self, cwd=None): # 相対パスの場合、指定のカレントディレクトリを結合してルート相対パス情報を格納する。
        try:
            if self.is相対パス():
                if len(cwd) > 0 and cwd.startswith('/'):
                    cwd = cwd if cwd.endswith('/') else cwd + '/'
                    self.url = urljoin(cwd, urlparse(self.url).path)
                    # 注:この使い方なら、os.path.joinでも同等の結果が得られる。
                else:
                    raise ValueError
            else:
                pass
        except:
            print('ERROR:不正なカレントディレクトリが指定',sys.stderr)
            sys.exit()
    
    # 判定関数 ------------

    def is絶対パス(self):
        if self.url.startswith('http://') or self.url.startswith('https://') or self.url.startswith('//'):
            return True
        return False

    def isルート相対パス(self):
        if ( not self.url.startswith('//') ) and self.url.startswith('/'):
            return True
        return False

    def ルート相対パス_matches(self,prefixdir): ####
        if self.ルート相対パス().startswith(prefixdir):
            return True
        return False

    def is相対パス(self):
        if self.is絶対パス():
            return False
        if self.isルート相対パス():
            return False
        return True
    
    def is_cssfile(self):
        if self.extension() in self.CSS:
            return True
        return False            

    def is_jsfile(self):
        if self.extension() in self.JS:
            return True
        return False            

    def is_imgfile(self):
        if self.extension() in self.IMG:
            return True
        return False            

    def is_htmlext(self):
        if self.extension() in ['html','htm','HTML','HTM']:
            return True
        return False            

    # 変換した値を戻す関数 ------------
    def change_scheme(self, to): 
        if self.is絶対パス():
            return re.sub('(https://|http://|//)',to,self.url)

    def change_netloc(self,to): # toはschemeから指定の文字列を指定する。また、ルート相対パス型であれば、実質「to」で指定のnetlocを追加するような動作とする。
        return to + self.ルート相対パス()

def 引数なしのメソッド走行確認(クラス, インスタンス):
    """
    リフレクション系(Pythonにおいて(でなくても)適切な用語でないかも...)のメソッドを使って、一括で走行確認
    """
    import inspect
    メソッド一覧 = inspect.getmembers(クラス, inspect.isfunction)
    # ↑はこんな↓感じ
    #In [244]: inspect.getmembers(ResourcePath,inspect.isfunction)                                                          
    #Out[244]: [('__init__', <function __main__.x.__init__(self, path)>)]

    # 1要素めにメソッド名が入っている
    アンスコ始まりでないメソッド一覧 = [m for m in メソッド一覧 if not m[0].startswith('_')]
    # 2要素めに関数定義?が入っているので、parametersでパラメータ(シグネチャ)が取れる。inspect.signatureも参照)
    runlist = [m for m in アンスコ始まりでないメソッド一覧 if len(inspect.signature(m[1]).parameters) == 1] # selfしか引数に持たないもの
    for i in runlist:
        メソッド名 = i[0]
        # getattrでメソッドを実行して結果を確認。print関数で雑な出力。
        print(インスタンス, '->', メソッド名, '->', getattr(インスタンス, メソッド名)())


a = [
    'https://example.com/a/b/c.html',
    'http://example.com/a/b/c.html',
    '//example.com/a/b/c.html',
    '/a/b/c.html',
    '/a/b/',
    '/a/b',
    '/a/b/index.html'
]

for i in a:    
    x = ResourcePath(i)
    引数なしのメソッド走行確認(ResourcePath,x)    

b = 'x/y.jpg'
c = '/XXX/YYY/'
x = ResourcePath(b)
x.set_cwd(c)
引数なしのメソッド走行確認(ResourcePath,x)    

b_dash = 'x/y.jpg'
x = ResourcePath(b)
# こちらは途中でエラーになる(期待値どおり)
引数なしのメソッド走行確認(ResourcePath,x)    

↓ 実行結果

https://example.com/a/b/c.html -> basename -> c.html
https://example.com/a/b/c.html -> dirname -> /a/b
https://example.com/a/b/c.html -> extension -> html
https://example.com/a/b/c.html -> is_cssfile -> False
https://example.com/a/b/c.html -> is_htmlext -> True
https://example.com/a/b/c.html -> is_imgfile -> False
https://example.com/a/b/c.html -> is_jsfile -> False
https://example.com/a/b/c.html -> isルート相対パス -> False
https://example.com/a/b/c.html -> is相対パス -> False
https://example.com/a/b/c.html -> is絶対パス -> True
https://example.com/a/b/c.html -> just_dirname -> b
https://example.com/a/b/c.html -> netloc -> example.com
https://example.com/a/b/c.html -> scheme -> https
https://example.com/a/b/c.html -> ルート相対パス -> /a/b/c.html
https://example.com/a/b/c.html -> ルート相対パス取得可能 -> True
http://example.com/a/b/c.html -> basename -> c.html
http://example.com/a/b/c.html -> dirname -> /a/b
http://example.com/a/b/c.html -> extension -> html
http://example.com/a/b/c.html -> is_cssfile -> False
http://example.com/a/b/c.html -> is_htmlext -> True
http://example.com/a/b/c.html -> is_imgfile -> False
http://example.com/a/b/c.html -> is_jsfile -> False
http://example.com/a/b/c.html -> isルート相対パス -> False
http://example.com/a/b/c.html -> is相対パス -> False
http://example.com/a/b/c.html -> is絶対パス -> True
http://example.com/a/b/c.html -> just_dirname -> b
http://example.com/a/b/c.html -> netloc -> example.com
http://example.com/a/b/c.html -> scheme -> http
http://example.com/a/b/c.html -> ルート相対パス -> /a/b/c.html
http://example.com/a/b/c.html -> ルート相対パス取得可能 -> True
//example.com/a/b/c.html -> basename -> c.html
//example.com/a/b/c.html -> dirname -> /a/b
//example.com/a/b/c.html -> extension -> html
//example.com/a/b/c.html -> is_cssfile -> False
//example.com/a/b/c.html -> is_htmlext -> True
//example.com/a/b/c.html -> is_imgfile -> False
//example.com/a/b/c.html -> is_jsfile -> False
//example.com/a/b/c.html -> isルート相対パス -> False
//example.com/a/b/c.html -> is相対パス -> False
//example.com/a/b/c.html -> is絶対パス -> True
//example.com/a/b/c.html -> just_dirname -> b
//example.com/a/b/c.html -> netloc -> example.com
//example.com/a/b/c.html -> scheme -> 
//example.com/a/b/c.html -> ルート相対パス -> /a/b/c.html
//example.com/a/b/c.html -> ルート相対パス取得可能 -> True
/a/b/c.html -> basename -> c.html
/a/b/c.html -> dirname -> /a/b
/a/b/c.html -> extension -> html
/a/b/c.html -> is_cssfile -> False
/a/b/c.html -> is_htmlext -> True
/a/b/c.html -> is_imgfile -> False
/a/b/c.html -> is_jsfile -> False
/a/b/c.html -> isルート相対パス -> True
/a/b/c.html -> is相対パス -> False
/a/b/c.html -> is絶対パス -> False
/a/b/c.html -> just_dirname -> b
/a/b/c.html -> netloc -> 
/a/b/c.html -> scheme -> 
/a/b/c.html -> ルート相対パス -> /a/b/c.html
/a/b/c.html -> ルート相対パス取得可能 -> True
/a/b/ -> basename -> 
/a/b/ -> dirname -> /a/b
/a/b/ -> extension -> 
/a/b/ -> is_cssfile -> False
/a/b/ -> is_htmlext -> False
/a/b/ -> is_imgfile -> False
/a/b/ -> is_jsfile -> False
/a/b/ -> isルート相対パス -> True
/a/b/ -> is相対パス -> False
/a/b/ -> is絶対パス -> False
/a/b/ -> just_dirname -> b
/a/b/ -> netloc -> 
/a/b/ -> scheme -> 
/a/b/ -> ルート相対パス -> /a/b/
/a/b/ -> ルート相対パス取得可能 -> True
/a/b -> basename -> b
/a/b -> dirname -> /a
/a/b -> extension -> 
/a/b -> is_cssfile -> False
/a/b -> is_htmlext -> False
/a/b -> is_imgfile -> False
/a/b -> is_jsfile -> False
/a/b -> isルート相対パス -> True
/a/b -> is相対パス -> False
/a/b -> is絶対パス -> False
/a/b -> just_dirname -> a
/a/b -> netloc -> 
/a/b -> scheme -> 
/a/b -> ルート相対パス -> /a/b
/a/b -> ルート相対パス取得可能 -> True
/a/b/index.html -> basename -> index.html
/a/b/index.html -> dirname -> /a/b
/a/b/index.html -> extension -> html
/a/b/index.html -> is_cssfile -> False
/a/b/index.html -> is_htmlext -> True
/a/b/index.html -> is_imgfile -> False
/a/b/index.html -> is_jsfile -> False
/a/b/index.html -> isルート相対パス -> True
/a/b/index.html -> is相対パス -> False
/a/b/index.html -> is絶対パス -> False
/a/b/index.html -> just_dirname -> b
/a/b/index.html -> netloc -> 
/a/b/index.html -> scheme -> 
/a/b/index.html -> ルート相対パス -> /a/b/index.html
/a/b/index.html -> ルート相対パス取得可能 -> True
/XXX/YYY/x/y.jpg -> basename -> y.jpg
/XXX/YYY/x/y.jpg -> dirname -> /XXX/YYY/x
/XXX/YYY/x/y.jpg -> extension -> jpg
/XXX/YYY/x/y.jpg -> is_cssfile -> False
/XXX/YYY/x/y.jpg -> is_htmlext -> False
/XXX/YYY/x/y.jpg -> is_imgfile -> True
/XXX/YYY/x/y.jpg -> is_jsfile -> False
/XXX/YYY/x/y.jpg -> isルート相対パス -> True
/XXX/YYY/x/y.jpg -> is相対パス -> False
/XXX/YYY/x/y.jpg -> is絶対パス -> False
/XXX/YYY/x/y.jpg -> just_dirname -> x
/XXX/YYY/x/y.jpg -> netloc -> 
/XXX/YYY/x/y.jpg -> scheme -> 
/XXX/YYY/x/y.jpg -> ルート相対パス -> /XXX/YYY/x/y.jpg
/XXX/YYY/x/y.jpg -> ルート相対パス取得可能 -> True
x/y.jpg -> basename -> y.jpg
x/y.jpg -> dirname -> x
x/y.jpg -> extension -> jpg
x/y.jpg -> is_cssfile -> False
x/y.jpg -> is_htmlext -> False
x/y.jpg -> is_imgfile -> True
x/y.jpg -> is_jsfile -> False
x/y.jpg -> isルート相対パス -> False
x/y.jpg -> is相対パス -> True
x/y.jpg -> is絶対パス -> False
x/y.jpg -> just_dirname -> x
x/y.jpg -> netloc -> 
x/y.jpg -> scheme -> 
ERROR:相対パスに対してルート相対パス情報取得 <_io.TextIOWrapper name='<stderr>' mode='w' encoding='utf-8'>