Kalmat / PyWinCtl

Cross-Platform module to get info on and control windows on screen
Other
191 stars 20 forks source link

Excessive execution time of getAllTitles() (MacOS) #2

Closed macdeport closed 2 years ago

macdeport commented 2 years ago

Test:

import pywinctl
import timeit

start_time= timeit.default_timer()
windows = pywinctl.getAllTitles()
duration=timeit.default_timer() - start_time
print(f'getAllTitles() {duration=}\n')

getAllTitles() duration=6.178130242

Kalmat commented 2 years ago

Hi there!

Unfortunately, Apple Script is slow. The execution times on my system are a little better (though tested on virtualbox), but not good at all. I managed to reduce getAllTitles() execution time, but it is not possible for other functions like getAllWindows():

['Proyectos', 'pywinctl — osascript ◂ Python _pywinctl_macos.py — 80×24', 'Abrir', 'Abrir', 'Favoritos', 'Contacts', 'Calendario']
getAllTitles() duration=1.177137069
getAllWindows() duration=2.181389136

Can you please try this on your system? If possible, open a number of random apps/windows.

import subprocess
import timeit

def getAllTitles():
    """Returns a list of strings of window titles for all visible windows."""
    cmd = """osascript -e 'tell application "System Events"
                            set winNames to {}
                            repeat with p in every process whose background only is false
                                repeat with w in every window of p
                                    set end of winNames to name of w
                                end repeat
                            end repeat
                            return winNames 
                            end tell'"""
    ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").strip().split(", ")
    return ret

start_time = timeit.default_timer()
windows = getAllTitles()
duration = timeit.default_timer() - start_time
print(f'getAllTitles() {duration=}\n')

Thank you!

Kalmat commented 2 years ago

Hi again!

I managed to improve performance (by some "non-intuitive" changes and workarounds). On my system, new measures are as follows:

['Proyectos', 'pywinctl — osascript ◂ Python _pywinctl_macos.py — 80×24', 'Favoritos', 'Contacts', 'Notas', 'Abrir', 'Abrir']

getAllTitles() duration=0.17727856399999997

getAllWindows() duration=0.462323936

getWindowsWithTitle() duration=0.4617865079999999

getMenu() duration=2.2305079800000005

They are high in absolute terms, but I don't think it's possible to improve them much more when using AppleScript (will keep on investigating anyway). getMenu() is specially hard to be improved.

If you can test on your system what I suggested in my previous comment, as well as this new version, I would really appreciate it!!!

Thanks!

macdeport commented 2 years ago
from pywinctl import *
import AppKit

import subprocess
import timeit

#def getAllTitles(app: AppKit.NSApplication = None) -> List[str]:
def getAllTitles(app: AppKit.NSApplication = None):
    """Returns a list of strings of window titles for all visible windows."""

    cmd = ("""'tell application "System Events"
                  set winNames to {}
                  repeat with p in every process whose background only is false
                      repeat with w in every window of p
                          set end of winNames to name of w
                      end repeat
                  end repeat
                  return winNames 
               end tell'""")
    cmd='osascript -e '+cmd #;print(cmd);1/0
    ret = subprocess.check_output(cmd,
                       shell=True).decode(encoding="utf-8").strip().split(", ")
    return ret

start_time = timeit.default_timer()
windows = getAllTitles()
duration = timeit.default_timer() - start_time
print(f'number of windows = {len(windows)}\ngetAllTitles() {duration = }')
$python3 pywinctl_timeit.py
windows number = 20
getAllTitles() duration = 1.532654942

Have you try *.scpt AppleScript file (pre-processed AppleScript code)?

To test your speedy code could you build the wheel (?) as I used pip update ;-)

Cheers

Kalmat commented 2 years ago

Thank you!!!

the wheel with new scripts and other modifications should be located at "dist" folder (version 0.0.18). If you get the time to test it, I would really appreciate it a lot!

Besides, I will dig into *scpt files (I had not hear from it before). If I am not wrong, it seems to be separate, pre-compiled script files. Perhaps it makes it much more difficult to pack and disttibute the library? We'll see!

Thanks for all your help.

macdeport commented 2 years ago

How to used the 0.0.18 wheel (which I have already downloaded)?

Kalmat commented 2 years ago

Use pip for that:

pip uninstall pywinctl
pip install PyWinCtl-0.0.18-py3-none-any.whl

Found and tested about scptd!

import subprocess
import timeit

def getAllTitlesB():
    """Returns a list of strings of window titles for all visible windows."""
    cmd = """osascript Test.scptd"""
    ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").strip().split(", ")
    return ret

def getAllTitles() :
    """Returns a list of strings of window titles for all visible windows."""
    cmd = """osascript -e 'tell application "System Events"
                                try
                                    set winNames to name of (every window of (every process whose background only is false))
                                end try
                           end tell
                           return winNames'"""
    ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").strip().split(", ")
    return ret

start_time = timeit.default_timer()
windows = getAllTitles()
duration = timeit.default_timer() - start_time
print(f'number of windows = {len(windows)}\ngetAllTitles() {duration = }')

start_time = timeit.default_timer()
windows = getAllTitlesB()
duration = timeit.default_timer() - start_time
print(f'number of windows = {len(windows)}\ngetAllTitlesB() {duration = }')

The result is not very encouraging though (dammit!):

number of windows = 7
getAllTitles() duration = 0.186754525
number of windows = 7
getAllTitlesB() duration = 0.20994049099999978

Inside Test.scptd there is exactly the same script code. I can provide you with it if you want to test it on you own system too.

Thank you so much again!

macdeport commented 2 years ago
from pywinctl import *
import AppKit

import subprocess
import sysconfig
import timeit

from pywinctl import __version__ as version
print(f'{sysconfig.get_platform()}\nPyWinctl v{version}')

#def getAllTitles(app: AppKit.NSApplication = None) -> List[str]:
def getAllTitles2(app: AppKit.NSApplication = None):
    """Returns a list of strings of window titles for all visible windows."""

    cmd = ("""'tell application "System Events"
                  set winNames to {}
                  repeat with p in every process whose background only is false
                      repeat with w in every window of p
                          set end of winNames to name of w
                      end repeat
                  end repeat
                  return winNames 
               end tell'""")
    cmd='osascript -e '+cmd #;print(cmd);1/0
    ret = subprocess.check_output(cmd,
                       shell=True).decode(encoding="utf-8").strip().split(", ")
    return ret

start_time = timeit.default_timer()
windows = getAllTitles2()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getAllTitles2() duration = {duration:.3f}')

start_time = timeit.default_timer()
windows = getAllTitles()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getAllTitles()  duration = {duration:.3f}')
macosx-10.10-x86_64
PyWinctl v0.0.18
windows number = 17
getAllTitles2() duration = 0.256
windows number = 18
getAllTitles()  duration = 0.085

Windows number discrepancy Feature request: version method

Cheers

Kalmat commented 2 years ago

Hi!

First off, It seems that the improvement has worked. To be honest, I didn't expect that enormous difference (from 6.17 to 0.085!!!). Happy to see that, anyway.

About windows number discrepancy, totally lost. There was a huge difference compared with v0.0.17, but not with getAllTitle2(), which was my first solution approach to improve performance. The difference between getAllTitles2() and getAllTitles() inside v0.0.18 version is basically... none! It's the same code, reordered in a different way (I realized "repeat" is really slow). This is actually the new 0.0.18 script:

        cmd = """osascript -e 'tell application "System Events"
                                   set winNames to {}
                                    try
                                        set winNames to name of (every window of (every process whose background only is false))
                                    end try
                                end tell
                                return winNames'"""

Whenever you try it again, please print the list of window names, so we can identify if there is something I am not properly catching. If I am not asking too much, please also include in your measurment tests getAllWindows() and getMenu() methods to check how they behave in "real" environments.

Finally, version method included. I am uploading a new version (0.0.19) which already contains this new method (version/getVersion).

Thank you SO much!

macdeport commented 2 years ago

Please also include in your measurment tests getAllWindows() and getMenu() methods to check how they behave in "real" environments.

Please provide the "real" code you need to test in "real" environment ;-)

Finally, version method included. I am uploading a new version (0.0.19) which already contains this new method (version/getVersion)

Nice, but I suggest version() == pywinctl.__version__ and getVersion(() as it is...

Whenever you try it again, please print the list of window names, so we can identify if there is something I am not properly catching.

Short private mail follows

Kalmat commented 2 years ago

Sure!

First, download wheel file and install 0.0.19 version:

pip uninstall pywinctl
pip install PyWinCtl-0.0.19-py3-none-any.whl

And then run this:

import pywinctl
import subprocess
import timeit

start_time = timeit.default_timer()
windows = getAllWindows()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getAllWindows() duration = {duration:.3f}')

start_time = timeit.default_timer()
windows = getAllTitles()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getAllTitles()  duration = {duration:.3f}')

subprocess.Popen(['open', '-a', 'TextEdit'])

start_time = timeit.default_timer()
windows = getWindowsWithTitle('TextEdit')
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getWindowsWithTitle()  duration = {duration:.3f}')

if windows:
    win = windows[0]

start_time = timeit.default_timer()
menu = win.getMenu()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getMenu()  duration = {duration:.3f}')

Regarding version methods, you mean version() should return "0.0.19" and getVersion() should return "PyWinCtl 0.0.19", right?

I have received your mail. Thank you! I see there is an empty ('') window in the list of getAllTitles() which is not present in getAllTitles2()... Extremely weird when comparing both scripts, and it doesn't happen in my virtual machine. I will look into it.

Kalmat commented 2 years ago

Found!

You won't believe it. It only happens in Yosemite. The solution is as simple as THIS (note the lack of parenthesis):

cmd = """osascript -e 'tell application "System Events"
                                   set winNames to {}
                                    try
                                        set winNames to name of every window of every process whose background only is false
                                    end try
                                end tell
                                return winNames'"""

Well seen!! Thank you!

macdeport commented 2 years ago

https://github.com/Kalmat/PyWinCtl/issues/2#issuecomment-1057183118

macosx-10.10-x86_64
PyWinCtl 0.0.19
windows number = 6
getAllWindows() duration = 0.156
windows number = 17
getAllTitles() duration = 0.098
windows number = 0
getWindowsWithTitle() duration = 0.531

When an updated wheel exist let me know

Kalmat commented 2 years ago

Found and fixed. Yesterday wasn't a good day for me at all, but this doesn't mean to waste others' time. Sorry for that. Version 0.0.20 is uploaded.

Besides, my code was wrong. I was trying to avoid language problems (if I open TextEdit, the window name is "Abrir", which I guess would not work on your system). This is the rigth code to test:

import pywinctl
import subprocess
import timeit

start_time = timeit.default_timer()
windows = getAllWindows()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getAllWindows() duration = {duration:.3f}')

start_time = timeit.default_timer()
windows = getAllTitles()
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getAllTitles()  duration = {duration:.3f}')

subprocess.Popen(['touch', 'test.txt'])
time.sleep(2)
subprocess.Popen(['open', '-a', 'TextEdit', 'test.txt'])
time.sleep(3)

start_time = timeit.default_timer()
windows = getWindowsWithTitle('test.txt')
duration = timeit.default_timer() - start_time
print(f'windows number = {len(windows)}\n'
      f'getWindowsWithTitle()  duration = {duration:.3f}')

win = None
if windows:
    win = windows[0]

if win:
    start_time = timeit.default_timer()
    menu = win.menu.getMenu()
    duration = timeit.default_timer() - start_time
    print(f'keys number = {len(menu.keys())}\n'
          f'getMenu()  duration = {duration:.3f}')
    win.close()
    subprocess.Popen(['rm', 'test.txt'])

Thank you for your patience, and my apologies again.

macdeport commented 2 years ago
macosx-10.10-x86_64
PyWinCtl-0.0.20
windows number = 6
getAllWindows() duration = 0.184
windows number = 19
getAllTitles()  duration = 0.119
windows number = 1
getWindowsWithTitle()  duration = 0.174
[MacOSWindow(hWnd=<NSRunningApplication: 0x7f9322e2f720 (com.apple.TextEdit - 16732)>)]
keys number = 8
getMenu()       duration = 1.604
getMenu() keys() = dict_keys(['Apple', 'TextEdit', 'Fichier', 'Édition', 'Format', 'Présentation', 'Fenêtre', 'Aide'])

I have to make this change: getWindowsWithTitle('TextEdit') => getWindowsWithTitle('test.txt')

Kalmat commented 2 years ago

Thank you!

Extremely weird that behavior: 6 vs. 19!?!?!?!?! When testing on both Yosemite and Catalina, this is what I get on version 0.0.20:

windows number = 10
getAllTitles()  duration = 0.220
['Proyectos', 'pywinctl — osascript ◂ Python _pywinctl_macos.py — 80×24', 'Favoritos', 'Notas', 'Contacts', 'Abrir', 'Abrir', 'Recordatorios', 'Calendario', 'Configuración de Acciones de Carpeta']
windows number = 10
getAllWindows() duration = 0.556
['Proyectos', 'pywinctl — osascript ◂ Python _pywinctl_macos.py — 80×24', 'Favoritos', 'Notas', 'Contacts', 'Abrir', 'Abrir', 'Recordatorios', 'Calendario', 'Configuración de Acciones de Carpeta']
windows number = 1
getWindowsWithTitle()  duration = 0.542
keys number = 8
getMenu()  duration = 3.084

Do you know if 6 or 19 or other is the right number of open windows on your system when running the test? Could you please email me with the print result for both getAllWindows() and getAllTitles()? Before that, please, donwload wheel again and uninstall/reinstall it on your system. Sorry, but I am not sure if we properly synced since getWindowsWithTitle('test.txt') was already present in my previous comment. In addition to all this fixes, I just uploaded a new wheel which contains one-liner versions of all problemmatic scripts (not for getMenu(), which still I don't know how to improve it), which should be even faster!

Of course, no compromise nor urgency.

macdeport commented 2 years ago
macosx-10.10-x86_64
PyWinCtl-0.0.20
windows number = 4
getAllWindows() duration = 0.175
[MacOSWindow(hWnd=<NSRunningApplication: 0x7f9ede160de0 (com.apple.iCal - 945)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f9ede1611e0 (com.barebones.bbedit - 9493)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f9ede1626e0 (com.apple.Terminal - 20049)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f9ede1627e0 (com.cocoatech.PathFinder - 20087)>)]

com.apple.iCal com.barebones.bbedit com.apple.Terminal com.cocoatech.PathFinder (with number of windows)

but other apps runningin MacOS Dock as Firefox, Safari, TextEdit are absent...

Kalmat commented 2 years ago

In the end, one-liner scripts are harder than I thought. They are way faster, but they produce very complex data structures which were misleading me. I think I finally catched it with your extremely appreciated help!!!

I am filtering for running apps only, this is why those other apps are not included in the ouput. Minimized apps/windows will be included, however. If you run this on a terminal: defaults write com.apple.dock static-only -bool true; killall Dock, those other apps shouldn't show as "running" on the dock.

I'm uploading version 0.0.21 which hopefully solves this.

macdeport commented 2 years ago
macosx-10.10-x86_64
0.0.21
windows number = 17
getAllWindows() duration = 0.186
[MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9796e50 (com.cocoatech.PathFinder - 414)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9797050 (com.barebones.bbedit - 455)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9797750 (org.mozilla.thunderbird - 2321)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9797850 (org.mozilla.firefox - 2328)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9797e50 (com.apple.TextEdit - 2489)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9797f50 (com.apple.Terminal - 2527)>),
 MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9798050 (com.apple.Safari - 2626)>)]

windows number = 19
getAllTitles()  duration = 0.119
windows number = 1
getWindowsWithTitle()  duration = 0.182
[MacOSWindow(hWnd=<NSRunningApplication: 0x7f89c9797e50 (com.apple.TextEdit - 2489)>)]
keys number = 8
getMenu()       duration = 1.535
getMenu() keys() = dict_keys(['Apple', 'TextEdit', 'Fichier', 'Édition', 'Format', 
 'Présentation', 'Fenêtre', 'Aide'])
Kalmat commented 2 years ago

Getting closer... HAHAHAHAHA! Can you please email me the list of titles the next time you test?

I will dig into it anyway.

Thanks a lot!!!

Kalmat commented 2 years ago

Again, complex structures issues. I even installed PathFinder and BBEdit to test on my own system. I think (hope) it is working OK now. Version 0.0.22 uploaded!!!

macdeport commented 2 years ago

https://github.com/Kalmat/PyWinCtl/issues/2#issuecomment-1058957547 mails sent.

Kalmat commented 2 years ago

So, is 0.0.22 ok in terms of time and windows number?