mik-automator 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bvandrc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: mik-automator
3
+ Version: 1.1.0
4
+ Summary: Windows automator and database tools for Mixed In Key 11
5
+ Author-email: bvandrc <bvandercar@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/bvandrc/mik-automator
8
+ Project-URL: Issues, https://github.com/bvandrc/mik-automator/issues
9
+ Project-URL: Changelog, https://github.com/bvandrc/mik-automator/blob/main/CHANGELOG.md
10
+ Keywords: mixed in key,mixedinkey,mik,dj,key detection,harmonic mixing,automation
11
+ Classifier: Environment :: Win32 (MS Windows)
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pywinauto
21
+ Requires-Dist: pywin32
22
+ Requires-Dist: comtypes
23
+ Provides-Extra: dev
24
+ Requires-Dist: black; extra == "dev"
25
+ Requires-Dist: pylint; extra == "dev"
26
+ Requires-Dist: pyright; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ Windows automator and database tools for Mixed In Key 11.
30
+
31
+ # Installation
32
+
33
+ ```bash
34
+ pip install mik-automator
35
+ ```
36
+
37
+ Windows only, Python 3.10+.
38
+
39
+ If you installed it from GitHub as `mixed-in-key-automator`, uninstall that first (`pip uninstall mixed-in-key-automator`): both install the `mik_automator` package, so keeping both leaves them overwriting each other's files.
40
+
41
+ # Usage
42
+
43
+ ## `mik_automator`
44
+
45
+ Opens Mixed In Key (or connects to it if it's already open), adds a folder, waits for the analysis to finish, then closes it.
46
+
47
+ ```python
48
+ from mik_automator import mik_automator
49
+
50
+ mik_automator.run(r"C:\Users\me\Music\DJ Tracks")
51
+ ```
52
+
53
+ It knows the analysis is done once Mixed In Key's database stops changing and the window closes without asking to confirm.
54
+
55
+ ## `mik_db`
56
+
57
+ Reads and edits Mixed In Key's database (`MIKStore.db`) directly.
58
+
59
+ - `rename_file(src, dest, names=None)`: points a song at its renamed file, so it keeps its analysis.
60
+ - `remove_files(filepaths)` and `remove_missing_files()`: remove songs, so they're analyzed again when added, or because their file is gone.
61
+ - `get_song_files()` and `get_files_missing_from_db(dir)`: read-only, safe while Mixed In Key is running.
62
+
63
+ Run `python -m mik_automator.mik_db` to remove the songs whose files no longer exist.
64
+
65
+ # Development
66
+
67
+ ```bash
68
+ python -m venv .venv
69
+ source .venv/Scripts/activate
70
+ pip install -e ".[dev]"
71
+ ```
72
+
73
+ Lint with `pylint $(git ls-files '*.py')` and `pyright`, and format with `black .`. CI runs all three on pull requests.
74
+
75
+ # License
76
+
77
+ [MIT](LICENSE)
@@ -0,0 +1,49 @@
1
+ Windows automator and database tools for Mixed In Key 11.
2
+
3
+ # Installation
4
+
5
+ ```bash
6
+ pip install mik-automator
7
+ ```
8
+
9
+ Windows only, Python 3.10+.
10
+
11
+ If you installed it from GitHub as `mixed-in-key-automator`, uninstall that first (`pip uninstall mixed-in-key-automator`): both install the `mik_automator` package, so keeping both leaves them overwriting each other's files.
12
+
13
+ # Usage
14
+
15
+ ## `mik_automator`
16
+
17
+ Opens Mixed In Key (or connects to it if it's already open), adds a folder, waits for the analysis to finish, then closes it.
18
+
19
+ ```python
20
+ from mik_automator import mik_automator
21
+
22
+ mik_automator.run(r"C:\Users\me\Music\DJ Tracks")
23
+ ```
24
+
25
+ It knows the analysis is done once Mixed In Key's database stops changing and the window closes without asking to confirm.
26
+
27
+ ## `mik_db`
28
+
29
+ Reads and edits Mixed In Key's database (`MIKStore.db`) directly.
30
+
31
+ - `rename_file(src, dest, names=None)`: points a song at its renamed file, so it keeps its analysis.
32
+ - `remove_files(filepaths)` and `remove_missing_files()`: remove songs, so they're analyzed again when added, or because their file is gone.
33
+ - `get_song_files()` and `get_files_missing_from_db(dir)`: read-only, safe while Mixed In Key is running.
34
+
35
+ Run `python -m mik_automator.mik_db` to remove the songs whose files no longer exist.
36
+
37
+ # Development
38
+
39
+ ```bash
40
+ python -m venv .venv
41
+ source .venv/Scripts/activate
42
+ pip install -e ".[dev]"
43
+ ```
44
+
45
+ Lint with `pylint $(git ls-files '*.py')` and `pyright`, and format with `black .`. CI runs all three on pull requests.
46
+
47
+ # License
48
+
49
+ [MIT](LICENSE)
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mik-automator"
7
+ version = "1.1.0"
8
+ description = "Windows automator and database tools for Mixed In Key 11"
9
+ requires-python = ">= 3.10"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "bvandrc", email = "bvandercar@outlook.com" },
15
+ ]
16
+ keywords = ["mixed in key", "mixedinkey", "mik", "dj", "key detection", "harmonic mixing", "automation"]
17
+ classifiers = [
18
+ "Environment :: Win32 (MS Windows)",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
24
+ ]
25
+ # Windows only: pywinauto drives the app through UI Automation, and pywin32 sends it window messages
26
+ dependencies = ["pywinauto", "pywin32", "comtypes"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["black", "pylint", "pyright"]
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/bvandrc/mik-automator"
33
+ Issues = "https://github.com/bvandrc/mik-automator/issues"
34
+ Changelog = "https://github.com/bvandrc/mik-automator/blob/main/CHANGELOG.md"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+ include = ["mik_automator*"]
39
+
40
+ [tool.black]
41
+ line-length = 120
42
+ target-version = ["py310"]
43
+
44
+ [tool.pyright]
45
+ # Pyright doesn't read .gitignore, so a stale build/ left behind by `pip install .`
46
+ # gets type-checked as a second copy of the source. Setting exclude replaces
47
+ # Pyright's defaults, hence __pycache__ and dotted dirs are restated here.
48
+ exclude = ["build", "dist", "**/__pycache__", "**/.*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,170 @@
1
+ import glob
2
+ import logging
3
+ import os
4
+ import time
5
+ from pathlib import Path
6
+ import ctypes
7
+ from typing import Callable
8
+
9
+ import win32con
10
+ import win32gui
11
+ from comtypes import COMError
12
+ from pywinauto.application import Application, WindowSpecification
13
+ from pywinauto.base_wrapper import BaseWrapper
14
+ from pywinauto.findwindows import find_windows
15
+ from pywinauto.remote_memory_block import RemoteMemoryBlock
16
+ from pywinauto.timings import TimeoutError as WaitTimeoutError, wait_until, wait_until_passes
17
+
18
+ from mik_automator import mik_db
19
+
20
+ MIK_TITLE_RE = "Mixed In Key.*"
21
+
22
+
23
+ def _get_exe_file():
24
+ root_dir = os.getenv("LOCALAPPDATA")
25
+ assert root_dir, "local appdata"
26
+ exe_files = glob.glob(
27
+ "Programs/Mixed In Key/Mixed In Key*/MixedInKey.exe",
28
+ root_dir=root_dir,
29
+ recursive=True,
30
+ )
31
+ assert len(exe_files) != 0, "mik .exe not found"
32
+ assert len(exe_files) == 1, "more than 1 mik .exe found"
33
+ return os.path.join(root_dir, exe_files[0])
34
+
35
+
36
+ # Create window (or connect to existing)
37
+ def _get_mik_window(create_new_if_not_open: bool):
38
+ app = Application(backend="uia")
39
+
40
+ for i in range(3):
41
+ try:
42
+ try:
43
+ app.connect(title_re=MIK_TITLE_RE)
44
+ except: # pylint: disable=bare-except
45
+ if create_new_if_not_open:
46
+ app.start(_get_exe_file())
47
+
48
+ window = app.top_window()
49
+ window.wait("ready", timeout=20)
50
+ window.wait("visible", timeout=20)
51
+ return window, app
52
+ except: # pylint: disable=bare-except
53
+ if i == 2:
54
+ if create_new_if_not_open: # otherwise, not finding it just means that it closed
55
+ logging.error("Could not open MIK")
56
+ raise
57
+ time.sleep(3)
58
+ raise Exception("Could not open MIK")
59
+
60
+
61
+ def _is_mik_open():
62
+ return len(find_windows(title_re=MIK_TITLE_RE)) > 0
63
+
64
+
65
+ def _select_folder(dialog: BaseWrapper, dir: str):
66
+ """Selects a folder in a Browse For Folder dialog, which has no box to type a path in. The message's path is read
67
+ by the dialog's process, so it has to be written into that process's memory."""
68
+ path = ctypes.create_unicode_buffer(dir)
69
+ memory = RemoteMemoryBlock(dialog, ctypes.sizeof(path))
70
+ try:
71
+ memory.Write(path)
72
+ # 0x0467 is BFFM_SETSELECTIONW, the Windows message that selects a folder. True says it's given a path string.
73
+ win32gui.SendMessage(dialog.handle, 0x0467, True, memory.Address())
74
+ finally:
75
+ memory.CleanUp()
76
+
77
+
78
+ def _add_folder(window: WindowSpecification, dir: str):
79
+ # Add Files button has no identifier, so open its dialog with the keyboard shortcut.
80
+ window.set_focus()
81
+ window.type_keys("^o")
82
+
83
+ # Add Folder button in dialog
84
+ add_folder_btn = window.child_window(best_match="ADD FOLDERButton")
85
+ add_folder_btn.click()
86
+
87
+ folder_browser_popup = window.child_window(best_match="Browse For Folder")
88
+ folder_browser_popup.wait("visible", timeout=20)
89
+ dialog = folder_browser_popup.wrapper_object()
90
+ _select_folder(dialog, os.path.abspath(dir))
91
+ time.sleep(1)
92
+
93
+ # OK after selected folder.
94
+ win32gui.PostMessage(dialog.handle, win32con.WM_COMMAND, win32con.IDOK, 0)
95
+
96
+
97
+ def _retry_on_com_error(wait: Callable[[], object]):
98
+ """Runs a wait on the window, again if it fails with a COMError, which reading a control as it closes can raise."""
99
+ wait_until_passes(60 * 5, 1, wait, COMError)
100
+
101
+
102
+ def _wait_for_files_added(window: WindowSpecification):
103
+ adding_files = window.child_window(best_match="Adding files to queue")
104
+ try:
105
+ _retry_on_com_error(lambda: adding_files.wait("visible", timeout=30))
106
+ except WaitTimeoutError:
107
+ pass # a quick add's popup can be gone before we look
108
+ _retry_on_com_error(lambda: adding_files.wait_not("visible", timeout=60 * 5))
109
+ _retry_on_com_error(lambda: window.child_window(control_type="ProgressBar").wait_not("visible", timeout=60 * 5))
110
+
111
+
112
+ def _get_db_state():
113
+ stat = os.stat(mik_db.db_file)
114
+ return stat.st_size, stat.st_mtime_ns
115
+
116
+
117
+ def _wait_for_db_to_go_quiet(quiet_seconds: int):
118
+ """Waits until the database stops changing. It's written to as each track finishes, so changes mean it's analyzing."""
119
+ state = _get_db_state()
120
+ last_change_time = time.time()
121
+ while time.time() - last_change_time < quiet_seconds:
122
+ time.sleep(1)
123
+ new_state = _get_db_state()
124
+ if new_state != state:
125
+ state = new_state
126
+ last_change_time = time.time()
127
+
128
+
129
+ def run(dir: str):
130
+ window, app = _get_mik_window(create_new_if_not_open=True)
131
+ # window.print_control_identifiers()
132
+
133
+ # Maximize window to make coordinate clicking consistent.
134
+ try:
135
+ maximize_btn = window.child_window(auto_id="MaximizeButton")
136
+ maximize_btn.click()
137
+ except: # pylint: disable=bare-except
138
+ pass # already maximized
139
+
140
+ window.child_window(control_type="ProgressBar").wait_not("visible", timeout=60 * 5)
141
+
142
+ _add_folder(window, dir)
143
+ _wait_for_files_added(window)
144
+
145
+ # Attempt to close once the database is quiet. If window closes, analysis done. If confirmation popup appears, a long
146
+ # analysis is still going, so wait for quiet again.
147
+ while True:
148
+ _wait_for_db_to_go_quiet(quiet_seconds=20)
149
+ close_button = window.child_window(auto_id="CloseButton")
150
+ close_button.click()
151
+ try:
152
+ # Window item gets all weird here, sometimes can't find the NO button. Re-connect to the window.
153
+ del app
154
+ window, app = _get_mik_window(create_new_if_not_open=False)
155
+ no_button = window.child_window(best_match="NOButton")
156
+ no_button.wait("visible", timeout=5)
157
+ except KeyboardInterrupt:
158
+ input("press any key to continue checking mik")
159
+ break
160
+ except Exception: # pylint: disable=broad-exception-caught
161
+ # No confirmation popup, so it should be closing. Fails if it stays open, rather than leaving it analyzing.
162
+ wait_until(30, 1, _is_mik_open, False)
163
+ break
164
+ no_button.click()
165
+
166
+
167
+ if __name__ == "__main__":
168
+ dir = os.path.join(Path.home(), "Music\\DJ Tracks")
169
+ print("adding dir: ", dir)
170
+ run(dir)
@@ -0,0 +1,98 @@
1
+ import os
2
+ import glob
3
+ import hashlib
4
+ import sqlite3
5
+ from contextlib import closing, contextmanager
6
+ from pathlib import Path
7
+ from typing import TypedDict
8
+
9
+ appdata_dir = os.getenv("LOCALAPPDATA")
10
+ assert appdata_dir, "local appdata"
11
+ db_file = os.path.join(appdata_dir, "Mixed In Key", "Mixed In Key", "11.0", "MIKStore.db")
12
+
13
+ AUDIO_EXTENSIONS = (".mp3", ".wav", ".flac", ".aif", ".aiff", ".m4a")
14
+
15
+
16
+ class ArtistTitle(TypedDict):
17
+ artist: str
18
+ title: str
19
+
20
+
21
+ @contextmanager
22
+ def connect():
23
+ """Opens the MIK database, committing on success and always closing."""
24
+ connection = sqlite3.connect(db_file)
25
+ try:
26
+ with connection:
27
+ yield connection
28
+ finally:
29
+ connection.close()
30
+
31
+
32
+ def get_song_files() -> set[str]:
33
+ """Returns every song's path, lowercased. Opens read-only, so it's safe while Mixed In Key is running."""
34
+ with closing(sqlite3.connect(f"{Path(db_file).as_uri()}?mode=ro", uri=True)) as connection:
35
+ return {row[0].lower() for row in connection.execute("SELECT File FROM Song")}
36
+
37
+
38
+ def get_files_missing_from_db(dir: str) -> list[str]:
39
+ song_files = get_song_files()
40
+ files = (f for f in glob.iglob(os.path.join(dir, "**", "*"), recursive=True) if os.path.isfile(f))
41
+ return [f for f in files if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS and f.lower() not in song_files]
42
+
43
+
44
+ def get_file_path_hash(filepath: str, disk_serial: str) -> str:
45
+ """Same as Mixed In Key's: paths are tied to the disk, so they still match if the drive letter changes."""
46
+ id_string = f"{disk_serial}:{filepath.split(':', 1)[1]}" if disk_serial.strip() else filepath
47
+ return hashlib.md5(id_string.lower().encode("utf-8")).hexdigest().upper()
48
+
49
+
50
+ def rename_file(src: str, dest: str, names: ArtistTitle | None = None) -> bool:
51
+ """Points a song at its renamed file, so it keeps its analysis. Returns whether the song was found.
52
+
53
+ Pass names when known, since filenames lose characters ("RÜFÜS DU SOL" is "RUFUS DU SOL"). Otherwise they come
54
+ from the filename, which must be "Artist - Title", as the file's tags may not be updated yet."""
55
+ if names is None:
56
+ filename = os.path.splitext(os.path.basename(dest))[0]
57
+ try:
58
+ artist, title = filename.split(" - ", 1)
59
+ except ValueError as e:
60
+ raise ValueError(f"couldn't parse {filename} as Artist - Title") from e
61
+ names = {"artist": artist, "title": title}
62
+
63
+ with connect() as connection:
64
+ row = connection.execute("SELECT DiskSerialNumber FROM Song WHERE File = ?", (src,)).fetchone()
65
+ if row is None:
66
+ return False
67
+ # src's file replaced whatever was at dest, so that song's row would be a duplicate
68
+ if src != dest:
69
+ connection.execute("DELETE FROM Song WHERE File = ?", (dest,))
70
+ connection.execute(
71
+ "UPDATE Song SET File = ?, FilePathHash = ?, ArtistName = ?, SongName = ? WHERE File = ?",
72
+ (dest, get_file_path_hash(dest, row[0]), names["artist"], names["title"], src),
73
+ )
74
+ return True
75
+
76
+
77
+ def remove_files(filepaths: list[str]):
78
+ if len(filepaths) == 0:
79
+ return
80
+ with connect() as connection:
81
+ connection.executemany("DELETE FROM Song WHERE File = ?", [(filepath,) for filepath in filepaths])
82
+
83
+
84
+ def remove_missing_files() -> list[str]:
85
+ """Removes songs whose file no longer exists. Skips files on drives that aren't connected. Returns the removed paths."""
86
+ with connect() as connection:
87
+ filepaths = [row[0] for row in connection.execute("SELECT File FROM Song")]
88
+
89
+ missing = [f for f in filepaths if os.path.exists(os.path.splitdrive(f)[0] + os.sep) and not os.path.exists(f)]
90
+ remove_files(missing)
91
+ return missing
92
+
93
+
94
+ if __name__ == "__main__":
95
+ removed = remove_missing_files()
96
+ for filepath in removed:
97
+ print(filepath)
98
+ print(f"Removed {len(removed)} missing files")
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: mik-automator
3
+ Version: 1.1.0
4
+ Summary: Windows automator and database tools for Mixed In Key 11
5
+ Author-email: bvandrc <bvandercar@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/bvandrc/mik-automator
8
+ Project-URL: Issues, https://github.com/bvandrc/mik-automator/issues
9
+ Project-URL: Changelog, https://github.com/bvandrc/mik-automator/blob/main/CHANGELOG.md
10
+ Keywords: mixed in key,mixedinkey,mik,dj,key detection,harmonic mixing,automation
11
+ Classifier: Environment :: Win32 (MS Windows)
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pywinauto
21
+ Requires-Dist: pywin32
22
+ Requires-Dist: comtypes
23
+ Provides-Extra: dev
24
+ Requires-Dist: black; extra == "dev"
25
+ Requires-Dist: pylint; extra == "dev"
26
+ Requires-Dist: pyright; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ Windows automator and database tools for Mixed In Key 11.
30
+
31
+ # Installation
32
+
33
+ ```bash
34
+ pip install mik-automator
35
+ ```
36
+
37
+ Windows only, Python 3.10+.
38
+
39
+ If you installed it from GitHub as `mixed-in-key-automator`, uninstall that first (`pip uninstall mixed-in-key-automator`): both install the `mik_automator` package, so keeping both leaves them overwriting each other's files.
40
+
41
+ # Usage
42
+
43
+ ## `mik_automator`
44
+
45
+ Opens Mixed In Key (or connects to it if it's already open), adds a folder, waits for the analysis to finish, then closes it.
46
+
47
+ ```python
48
+ from mik_automator import mik_automator
49
+
50
+ mik_automator.run(r"C:\Users\me\Music\DJ Tracks")
51
+ ```
52
+
53
+ It knows the analysis is done once Mixed In Key's database stops changing and the window closes without asking to confirm.
54
+
55
+ ## `mik_db`
56
+
57
+ Reads and edits Mixed In Key's database (`MIKStore.db`) directly.
58
+
59
+ - `rename_file(src, dest, names=None)`: points a song at its renamed file, so it keeps its analysis.
60
+ - `remove_files(filepaths)` and `remove_missing_files()`: remove songs, so they're analyzed again when added, or because their file is gone.
61
+ - `get_song_files()` and `get_files_missing_from_db(dir)`: read-only, safe while Mixed In Key is running.
62
+
63
+ Run `python -m mik_automator.mik_db` to remove the songs whose files no longer exist.
64
+
65
+ # Development
66
+
67
+ ```bash
68
+ python -m venv .venv
69
+ source .venv/Scripts/activate
70
+ pip install -e ".[dev]"
71
+ ```
72
+
73
+ Lint with `pylint $(git ls-files '*.py')` and `pyright`, and format with `black .`. CI runs all three on pull requests.
74
+
75
+ # License
76
+
77
+ [MIT](LICENSE)
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/mik_automator/__init__.py
5
+ src/mik_automator/mik_automator.py
6
+ src/mik_automator/mik_db.py
7
+ src/mik_automator.egg-info/PKG-INFO
8
+ src/mik_automator.egg-info/SOURCES.txt
9
+ src/mik_automator.egg-info/dependency_links.txt
10
+ src/mik_automator.egg-info/requires.txt
11
+ src/mik_automator.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ pywinauto
2
+ pywin32
3
+ comtypes
4
+
5
+ [dev]
6
+ black
7
+ pylint
8
+ pyright
@@ -0,0 +1 @@
1
+ mik_automator