この記事は、Pythonista3 Advent Calendar 2022 の12日目の記事です。
ほぼ毎日iPhone(Pythonista3)で、コーディングをしている者です。よろしくお願いします。
一方的な偏った目線で、Pythonista3 を紹介していきます。
以下、私の2022年12月時点の環境です。
--- SYSTEM INFORMATION ---
* Pythonista 3.3 (330025), Default interpreter 3.6.1
* iOS 16.1.1, model iPhone12,1, resolution (portrait) 828.0 x 1792.0 @ 2.0) 828.0 x 1792.0 @ 2.0
他の環境(iPad や端末の種類、iOS のバージョン違い)では、意図としない挙動(エラーになる)なる場合もあります。ご了承ください。
ちなみに、model iPhone12,1
は、iPhone11 です。
もうPythonista3 は、iOS(iPadOS)のインターフェースなんですわ
(発言者がおっさんなのかお嬢様なのかは各自のご想像にお任せします)
前回・前々回とPythonista3で、Pythonista3 の内部のディレクトリ構成を確認し、実際にデータにアクセスをしました。
print
を使ったconsole 出力では、
書く → 実行 → 確認 → (メモ) → 書き換え → 実行 → 確認...
のイテレーションで、なかなか骨が折れる作業でした。
そこでwebbrowser
モジュールを使い、GUI 上で確認ができるようになり、ディレクトリ間の移動も容易になったらおかげで、該当ファイルの確認も、普段のPythonista3 操作と同様におこなえるようになりました。
macOS のFinder、Windows のExplorer に近しい感覚になっていくのもまた、嬉しいものですね。
今回はPythonista3 内部より広い視野を持ち、iOS, iPadOS の中身を見に行こうとおもいます。
注意事項
免責事項として書いています。
アプリやOS 内部構造を操作することになります。
変更や削除で、アプリやOS が起動不可になることもありえます。
不具合に関しては自己責任でお願い致します。
macOS で(よく)見るあれ
iOS
のroot(ディレクトリの最上位)の構成をみてみましょう:
/
- .ba
- .file
- .mb
- Applications
- Developer
- Library
- System
- bin
- cores
- dev
- etc
- private
- sbin
- tmp
- usr
- var
実行したコード:
from pathlib import Path
import webbrowser
def parent_level(level_int):
return '../' * level_int
origin_path = Path()
level = 9
target_path = origin_path / parent_level(level)
show_list = sorted(list(target_path.glob('*')))
print(target_path.resolve())
for i in show_list:
print(' - ' + i.name)
#webbrowser.open(f'pythonista3:/{target_path}')
macOS をお使いの方なら、なんとなく見覚えがあるフォルダ名があるのではないでしょうか?
ざっとご紹介できそうな、ファイルを見ていきましょう!
先ほど使ったディレクトリ構成出力用コードに、コメントアウトなどして再利用しましょうか:
from pathlib import Path
import webbrowser
def parent_level(level_int):
return '../' * level_int
origin_path = Path()
level = 9
target_path = origin_path / parent_level(level)
'''
show_list = sorted(list(target_path.glob('*')))
print(target_path.resolve())
for i in show_list:
print(' - ' + i.name)
'''
webbrowser.open(f'pythonista3:/{target_path}')
/System/Library/Audio/UISounds/
iPhone のサウンドデータが格納されています。
いつもお馴染みのものや「これどこで使われてるん」的なものもありますね。
サウンド確認用と、OS が更新された時にサウンドに差分がないか確認する用で作ったリポジトリです。
(GIF なので音が鳴りませんが、雰囲気だけでも)
-
list.py | pome-ta/pysta-UISounds
-
ui
モジュールのtableview
と、ListDataSource
を使用 - 一列一挙に表示される
- サウンド名をタップすると音が確認できる
- あわせて、タップをするとconsole にファイルパスを表示
-
現在、294ほどのサウンドが格納されているみたいです(0
カウントスタートなので)。
-
pad.py | pome-ta/pysta-UISounds
-
ui
モジュールを使用 - 4列で分け、パッドでサンプラーのような実装
- タップをすると、その箇所を強調するようなエフェクト
- navigationView 右上のボタンで、現在再生中のサウンド停止
-
-
outputlog.py | pome-ta/pysta-UISounds
- 実行時のOS と、
/System/Library/Audio/UISounds/
情報を出力 - README.md | pome-ta/pysta-UISounds へ直接貼り付けできるよう整形
- diff 確認ができる
- 実行時のOS と、
/System/Library/Frameworks/
Framework の一覧です。え?なんのFramework か?
iOS 開発する際に使用するFramework たちです
- AVFoundation | Apple Developer Documentation
- CarPlay | Apple Developer Documentation
- SwiftUI | Apple Developer Documentation
- UIKit - 日本語ドキュメント - Apple Developer
などなどなどなど、、、
残念ならがファイル形式上(?)中身を見ることまではできませんが、サイトのDocumentation ではなく一覧が確認できるのは壮観ですね。
ちなみに/System/Library/PrivateFrameworks/
も一覧確認できます。プライベートなのでもちろんDocumentation に情報がないFramework です。
ファイルビューアーでも作りますか?
いままで、webbrowser.open
を使いディレクトリ探索をしてきました。
webbrowser
モジュールでも不都合は無いのですが、UISounds/
の一覧が見れたようにui
モジュール等を使って何かしらできそうですね(webbrowser.open
の1行っての寂しさがあります(趣味の場合のみ限定))。
Pythonista の集合知
検索をすれば断片的に出てきますし、Pythonista Community Forum から、解決してそうなコードを持ってくるのもありです。しかし、結構煩雑な作業です。
実はPythonista3 のPythonista たちが書いたコードが、まとまっているリポジトリがあります。
tdamdouni/Pythonista: Collection of Python Scripts written for Pythonista iOS App
めちゃくちゃあります。用途ごとに分けられているので「これどう使うのやろか?」みたいな探し方も可能です。
私的には、Pythonista3 作者のomz 氏の書いたコードがまとめられている、こちらのコードを見てみるのが良いかと思います。
Pythonista/omz | tdamdouni/Pythonista
作者自身が書いているので、Pythonista3 をどうやって使って欲しいか?どう書かれる想定なのか?も、感じ取れるのではないかなと思います。
File Picker.py
File Picker.py | tdamdouni/Pythonista という、それっぽいものがあるので、こちらを参考に必要に応じて改変していくとこにしましょう。
作者のgist File Picker.py にも、同様のコードがあります。
difff《デュフフ》 で差分確認したところ、先頭の記載情報以外の差分は見つからなかったので、どちらを使っても問題ないです。
挙動をみてみる
gist のFile Picker.py を使いました
実行してみると
-
Documents
の階層から開始 - ディレクトリ階層が開かれていく流れ
-
.py
ファイルしか選べない? - (dotFiles は表示されない)
-
[Done]
で確定したら何か通信してる - 新しいView が開かれる
といった流れであることがわかりました。
とりあえず
- 不要そうな部分の削除
- FTP(?)通信
- 自分の実装したい機能
- root から表示
- 開けるものは、Pythonista3 のエディタで開く
を、(無理やり)適用させていきましょう。
カスタマイズ
# coding: utf-8
import ui
import os
from objc_util import ObjCInstance, ObjCClass
from operator import attrgetter
#import time
import threading
import functools
#import ftplib
import re
# http://stackoverflow.com/a/6547474
def human_size(size_bytes):
'''Helper function for formatting human-readable file sizes'''
if size_bytes == 1:
return "1 byte"
suffixes_table = [('bytes', 0), ('KB', 0), ('MB', 1), ('GB', 2), ('TB', 2),
('PB', 2)]
num = float(size_bytes)
for suffix, precision in suffixes_table:
if num < 1024.0:
break
num /= 1024.0
if precision == 0:
formatted_size = "%d" % num
else:
formatted_size = str(round(num, ndigits=precision))
return "%s %s" % (formatted_size, suffix)
class TreeNode(object):
def __init__(self):
self.expanded = False
self.children = None
self.leaf = True
self.title = ''
self.subtitle = ''
self.icon_name = None
self.level = 0
self.enabled = True
def expand_children(self):
self.expanded = True
self.children = []
def collapse_children(self):
self.expanded = False
def __repr__(self):
return '<TreeNode: "%s"%s>' % (self.title, ' (expanded)'
if self.expanded else '')
class FileTreeNode(TreeNode):
def __init__(self, path, show_size=True, select_dirs=True,
file_pattern=None):
TreeNode.__init__(self)
self.path = path
self.title = os.path.split(path)[1]
self.select_dirs = select_dirs
self.file_pattern = file_pattern
is_dir = os.path.isdir(path)
self.leaf = not is_dir
ext = os.path.splitext(path)[1].lower()
if is_dir:
self.icon_name = 'Folder'
elif ext == '.py':
self.icon_name = 'FilePY'
elif ext == '.pyui':
self.icon_name = 'FileUI'
elif ext in ('.png', '.jpg', '.jpeg', '.gif'):
self.icon_name = 'FileImage'
else:
self.icon_name = 'FileOther'
self.show_size = show_size
if not is_dir and show_size:
self.subtitle = human_size((os.stat(self.path).st_size))
if is_dir and not select_dirs:
self.enabled = False
elif not is_dir:
filename = os.path.split(path)[1]
self.enabled = not file_pattern or re.match(file_pattern, filename)
@property
def cmp_title(self):
return self.title.lower()
def expand_children(self):
if self.children is not None:
self.expanded = True
return
files = os.listdir(self.path)
children = []
for filename in files:
if filename.startswith('.'):
continue
full_path = os.path.join(self.path, filename)
node = FileTreeNode(full_path, self.show_size, self.select_dirs,
self.file_pattern)
node.level = self.level + 1
children.append(node)
self.expanded = True
self.children = sorted(children, key=attrgetter('leaf', 'cmp_title'))
'''
# Just a simple demo of a custom TreeNode class... The TreeDialogController should be initialized with async_mode=True when using this class.
class FTPTreeNode(TreeNode):
def __init__(self, host, path=None, level=0):
TreeNode.__init__(self)
self.host = host
self.path = path
self.level = level
if path:
self.title = os.path.split(path)[1]
else:
self.title = self.host
self.leaf = path and len(os.path.splitext(path)[1]) > 0
self.icon_name = 'FileOther' if self.leaf else 'Folder'
def expand_children(self):
ftp = ftplib.FTP(self.host, timeout=10)
ftp.login('anonymous')
names = ftp.nlst(self.path or '')
ftp.quit()
self.children = [
FTPTreeNode(self.host, name, self.level + 1) for name in names
]
self.expanded = True
'''
class TreeDialogController(object):
def __init__(self, root_node, allow_multi=False, async_mode=False):
self.async_mode = async_mode
self.allow_multi = allow_multi
self.selected_entries = None
self.table_view = ui.TableView()
self.table_view.frame = (0, 0, 500, 500)
self.table_view.data_source = self
self.table_view.delegate = self
self.table_view.flex = 'WH'
self.table_view.allows_multiple_selection = True
self.table_view.tint_color = 'gray'
self.view = ui.View(frame=self.table_view.frame)
self.view.add_subview(self.table_view)
self.view.name = root_node.title
self.busy_view = ui.View(
frame=self.view.bounds, flex='WH', background_color=(0, 0, 0, 0.35))
hud = ui.View(frame=(self.view.center.x - 50, self.view.center.y - 50, 100,
100))
hud.background_color = (0, 0, 0, 0.7)
hud.corner_radius = 8.0
hud.flex = 'TLRB'
spinner = ui.ActivityIndicator()
spinner.style = ui.ACTIVITY_INDICATOR_STYLE_WHITE_LARGE
spinner.center = (50, 50)
spinner.start_animating()
hud.add_subview(spinner)
self.busy_view.add_subview(hud)
self.busy_view.alpha = 0.0
self.view.add_subview(self.busy_view)
self.done_btn = ui.ButtonItem(title='Done', action=self.done_action)
if self.allow_multi:
self.view.right_button_items = [self.done_btn]
self.done_btn.enabled = False
self.root_node = root_node
self.entries = []
self.flat_entries = []
if self.async_mode:
self.set_busy(True)
t = threading.Thread(target=self.expand_root)
t.start()
else:
self.expand_root()
def expand_root(self):
self.root_node.expand_children()
self.set_busy(False)
self.entries = self.root_node.children
self.flat_entries = self.entries
self.table_view.reload()
def flatten_entries(self, entries, dest=None):
if dest is None:
dest = []
for entry in entries:
dest.append(entry)
if not entry.leaf and entry.expanded:
self.flatten_entries(entry.children, dest)
return dest
def rebuild_flat_entries(self):
self.flat_entries = self.flatten_entries(self.entries)
def tableview_number_of_rows(self, tv, section):
return len(self.flat_entries)
def tableview_cell_for_row(self, tv, section, row):
cell = ui.TableViewCell()
entry = self.flat_entries[row]
level = entry.level - 1
image_view = ui.ImageView(frame=(44 + 20 * level, 5, 34, 34))
label_x = 44 + 34 + 8 + 20 * level
label_w = cell.content_view.bounds.w - label_x - 8
if entry.subtitle:
label_frame = (label_x, 0, label_w, 26)
sub_label = ui.Label(frame=(label_x, 26, label_w, 14))
sub_label.font = ('<System>', 12)
sub_label.text = entry.subtitle
sub_label.text_color = '#999'
cell.content_view.add_subview(sub_label)
else:
label_frame = (label_x, 0, label_w, 44)
label = ui.Label(frame=label_frame)
if entry.subtitle:
label.font = ('<System>', 15)
else:
label.font = ('<System>', 18)
label.text = entry.title
label.flex = 'W'
cell.content_view.add_subview(label)
if entry.leaf and not entry.enabled:
label.text_color = '#999'
cell.content_view.add_subview(image_view)
if not entry.leaf:
has_children = entry.expanded
btn = ui.Button(image=ui.Image.named('CollapseFolder' if has_children
else 'ExpandFolder'))
btn.frame = (20 * level, 0, 44, 44)
btn.action = self.expand_dir_action
cell.content_view.add_subview(btn)
if entry.icon_name:
image_view.image = ui.Image.named(entry.icon_name)
else:
image_view.image = None
cell.selectable = entry.enabled
return cell
def row_for_view(self, sender):
'''Helper to find the row index for an 'expand' button'''
cell = ObjCInstance(sender)
while not cell.isKindOfClass_(ObjCClass('UITableViewCell')):
cell = cell.superview()
return ObjCInstance(self.table_view).indexPathForCell_(cell).row()
def expand_dir_action(self, sender):
'''Invoked by 'expand' button'''
row = self.row_for_view(sender)
entry = self.flat_entries[row]
if entry.expanded:
sender.image = ui.Image.named('ExpandFolder')
else:
sender.image = ui.Image.named('CollapseFolder')
self.toggle_dir(row)
self.update_done_btn()
def toggle_dir(self, row):
'''Expand or collapse a folder node'''
entry = self.flat_entries[row]
if entry.expanded:
entry.collapse_children()
old_len = len(self.flat_entries)
self.rebuild_flat_entries()
num_deleted = old_len - len(self.flat_entries)
deleted_rows = range(row + 1, row + num_deleted + 1)
self.table_view.delete_rows(deleted_rows)
else:
if self.async_mode:
self.set_busy(True)
expand = functools.partial(self.do_expand, entry, row)
t = threading.Thread(target=expand)
t.start()
else:
self.do_expand(entry, row)
def do_expand(self, entry, row):
'''Actual folder expansion (called on background thread if async_mode is enabled)'''
entry.expand_children()
self.set_busy(False)
old_len = len(self.flat_entries)
self.rebuild_flat_entries()
num_inserted = len(self.flat_entries) - old_len
inserted_rows = range(row + 1, row + num_inserted + 1)
self.table_view.insert_rows(inserted_rows)
def tableview_did_select(self, tv, section, row):
self.update_done_btn()
def tableview_did_deselect(self, tv, section, row):
self.update_done_btn()
def update_done_btn(self):
'''Deactivate the done button when nothing is selected'''
selected = [
self.flat_entries[i[1]] for i in self.table_view.selected_rows
if self.flat_entries[i[1]].enabled
]
if selected and not self.allow_multi:
self.done_action(None)
else:
self.done_btn.enabled = len(selected) > 0
def set_busy(self, flag):
'''Show/hide spinner overlay'''
def anim():
self.busy_view.alpha = 1.0 if flag else 0.0
ui.animate(anim)
def done_action(self, sender):
self.selected_entries = [
self.flat_entries[i[1]] for i in self.table_view.selected_rows
if self.flat_entries[i[1]].enabled
]
self.view.close()
def file_picker_dialog(title=None,
root_dir=None,
multiple=False,
select_dirs=False,
file_pattern=None,
show_size=True):
if root_dir is None:
root_dir = os.path.expanduser('~/Documents')
if title is None:
title = os.path.split(root_dir)[1]
'''
root_node = FileTreeNode(
os.path.expanduser('~/Documents'), show_size, select_dirs, file_pattern)
'''
root_node = FileTreeNode(root_dir, show_size, select_dirs,
file_pattern) # 指定したディレクトリで開けるように
root_node.title = title or ''
picker = TreeDialogController(root_node, allow_multi=multiple)
#picker.view.present('sheet')
picker.view.present(
style='fullscreen', orientations=['portrait']) # 縦位置フルスクリーンの固定
picker.view.wait_modal()
if picker.selected_entries is None:
return None
paths = [e.path for e in picker.selected_entries]
if multiple:
return paths
else:
return paths[0]
'''
def ftp_dialog(host='mirrors.kernel.org'):
# This is just a demo of how TreeDialogController is
# extensible with custom TreeNode subclasses, so there
# aren't as many options as for the regular file dialog.
root_node = FTPTreeNode(host)
picker = TreeDialogController(root_node, async_mode=True)
picker.view.present('sheet')
picker.view.wait_modal()
if picker.selected_entries:
return picker.selected_entries[0].path
'''
def main():
root = os.path.expanduser('/') # ここでディレクトリ位置を決める
py_files = file_picker_dialog(
#'Pick some .py files',
title=None,
root_dir=root,
#multiple=True,
multiple=False, # 一つだけ選択
select_dirs=False,
#file_pattern=r'^.*\.py$')
file_pattern=r'^.*\.*$') # .py 以外も取れるように
#print('Picked from ~/Documents:', py_files)
if py_files:
import editor
editor.open_file(py_files, True) # もし開けるなら開く
'''
ftp_file = ftp_dialog()
print('Picked from FTP server:', ftp_file)
'''
if __name__ == '__main__':
main()
Reformat はかけていますが、基本的に元のコードを残しつつ、変更点はコメントアウトしてその直下に書き換えのコードを入れています。
アクセスできないディレクトリをタップしても、エラーは出ますが継続してディレクトリ移動できます(力技)。
カスタマイズした部分は、表層の部分のみなので変更点は、コードの下の方がメインになっていますね。
最悪使用者は、自分のみなので過度なエラーハンドリングをせずに、ゴリゴリとトライアンドエラーしてもいいでしょう。
(言い訳しますと、この記事と同時並行にカスタムしていったので、ガバガバのガバです🙇 深く検証できていません)
元のコードを削ったり戻したりしながら、モジュールの挙動を確認して、自分の思い描くアプリにしていくのも面白いですね。
次回は
OS の内部の探訪をおこないました。それに合わせて、自分で使いやすいツールを探したり、作ったり改造したりしました。
探訪のおともになりましたら、幸いです。
ユーザーの集合知から、色々探してPythonista3 力を上げていくのもいいですね。
tdamdouni/Pythonista: Collection of Python Scripts written for Pythonista iOS App
今回までで、探訪シリーズは終了です。
次回から何かと目を背けてきたobjc_util
モジュールを中心に紹介したいと考えています。
ここまで、読んでいただきありがとうございました。
せんでん
Discord
Pythonista3 の日本語コミュニティーがあります。みなさん優しくて、わからないところも親身に教えてくれるのでこの機会に覗いてみてください。
書籍
iPhone/iPad でプログラミングする最強の本。
その他
- サンプルコード
Pythonista3 Advent Calendar 2022 でのコードをまとめているリポジトリがあります。
コードのエラーや変なところや改善点など。ご指摘やPR お待ちしておりますー
なんしかガチャガチャしていますが、お気兼ねなくお声がけくださいませー
やれるか、やれないか。ではなく、やるんだけども、紹介説明することは尽きないと思うけど、締め切り守れるか?って話よ!(クズ)
— pome-ta (@pome_ta93) November 4, 2022
Pythonista3 Advent Calendar 2022 https://t.co/JKUxA525Pt #Qiita
- GitHub
基本的にGitHub にコードをあげているので、何にハマって何を実装しているのか観測できると思います。