0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Pythonista3Advent Calendar 2022

Day 12

ディレクトリ探訪の局地へ!OS の中身を覗きに行こう!(世の中にあるPythonista3 のコードをカスタムしてより良いツールへ)

Last updated at Posted at 2022-12-11

この記事は、Pythonista3 Advent Calendar 2022 の12日目の記事です。

ほぼ毎日iPhone(Pythonista3)で、コーディングをしている者です。よろしくお願いします。

一方的な偏った目線で、Pythonista3 を紹介していきます。

以下、私の2022年12月時点の環境です。

sysInfo.log
--- 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}')

img221129_023203.gif

/System/Library/Audio/UISounds/

iPhone のサウンドデータが格納されています。

いつもお馴染みのものや「これどこで使われてるん」的なものもありますね。

pome-ta/pysta-UISounds

サウンド確認用と、OS が更新された時にサウンドに差分がないか確認する用で作ったリポジトリです。

(GIF なので音が鳴りませんが、雰囲気だけでも)

img221129_121144.gif

  • list.py | pome-ta/pysta-UISounds
    • ui モジュールのtableview と、ListDataSource を使用
    • 一列一挙に表示される
    • サウンド名をタップすると音が確認できる
    • あわせて、タップをするとconsole にファイルパスを表示

img221129_121223.png

現在、294ほどのサウンドが格納されているみたいです(0 カウントスタートなので)。

img221129_121231.png

  • pad.py | pome-ta/pysta-UISounds
    • ui モジュールを使用
    • 4列で分け、パッドでサンプラーのような実装
    • タップをすると、その箇所を強調するようなエフェクト
    • navigationView 右上のボタンで、現在再生中のサウンド停止

img221129_121755.gif

img221129_121837.png

/System/Library/Frameworks/

Framework の一覧です。え?なんのFramework か?

iOS 開発する際に使用するFramework たちです

などなどなどなど、、、

残念ならがファイル形式上(?)中身を見ることまではできませんが、サイトの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《デュフフ》 で差分確認したところ、先頭の記載情報以外の差分は見つからなかったので、どちらを使っても問題ないです。

img221129_131343.png

挙動をみてみる

gist のFile Picker.py を使いました

img221129_145104.gif

実行してみると

  • Documents の階層から開始
  • ディレクトリ階層が開かれていく流れ
  • .py ファイルしか選べない?
  • (dotFiles は表示されない)
  • [Done] で確定したら何か通信してる
  • 新しいView が開かれる

といった流れであることがわかりました。

とりあえず

  • 不要そうな部分の削除
    • FTP(?)通信
  • 自分の実装したい機能
    • root から表示
    • 開けるものは、Pythonista3 のエディタで開く

を、(無理やり)適用させていきましょう。

カスタマイズ

img221129_202942.gif

# 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 お待ちしておりますー

  • Twitter

なんしかガチャガチャしていますが、お気兼ねなくお声がけくださいませー

  • GitHub

基本的にGitHub にコードをあげているので、何にハマって何を実装しているのか観測できると思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?