Skip to content

Commit 5f039ae

Browse files
committedFeb 25, 2025·
Python REPL, kivy_console, local cryptography recipe
1 parent e5d27ed commit 5f039ae

26 files changed

+1331
-222
lines changed
 

‎.github/workflows/android.yml

+25-32
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,31 @@ jobs:
55
strategy:
66
matrix:
77
os:
8-
- 'ubuntu-latest'
8+
- "ubuntu-latest"
99
runs-on: ${{ matrix.os }}
1010
steps:
11-
- name: Setup python
12-
uses: actions/setup-python@v2
13-
with:
14-
python-version: 3.8
15-
- uses: actions/checkout@v2
16-
- uses: actions/cache@v2
17-
with:
18-
path: |
19-
~/.buildozer
20-
.buildozer
21-
key: ${{ hashFiles('buildozer.spec') }}
22-
23-
- name: Setup environment
24-
run: |
25-
pip install buildozer
26-
pip install Cython
27-
- run: buildozer --help
28-
- name: SDK, NDK and p4a download
29-
run: |
30-
sed -i.bak "s/# android.accept_sdk_license = False/android.accept_sdk_license = True/" buildozer.spec
31-
buildozer android p4a -- --help
32-
- name: Install Linux dependencies
33-
if: matrix.os == 'ubuntu-latest'
34-
run: sudo apt -y install automake
35-
- name: buildozer android debug
36-
run: |
37-
touch main.py
38-
buildozer android debug
39-
- uses: actions/upload-artifact@v2
40-
with:
41-
path: bin/*.apk
11+
- name: Setup python
12+
uses: actions/setup-python@v2
13+
with:
14+
python-version: 3.8
15+
- uses: actions/checkout@v2
16+
- uses: actions/cache@v4
17+
with:
18+
path: |
19+
~/.buildozer
20+
.buildozer
21+
key: ${{ hashFiles('tools/build/buildozer.spec') }}
4222

23+
- name: Setup environment
24+
run: |
25+
pip install buildozer
26+
pip install Cython
27+
- name: Install Linux dependencies
28+
if: matrix.os == 'ubuntu-latest'
29+
run: sudo apt -y install automake
30+
- name: buildozer android debug
31+
run: |
32+
sh tools/build/build-android.sh
33+
- uses: actions/upload-artifact@v4.6.1
34+
with:
35+
path: .buildozer/bin/*.apk

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
.buildozer
2+
*.pyc
3+
__pycache__/
24
bin

‎libs/garden/garden.navigationdrawer/LICENSE

-19
This file was deleted.

‎libs/garden/garden.navigationdrawer/README.md

-128
This file was deleted.
-23.4 KB
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
File renamed without changes.

‎icon.png ‎remoteshell/data/icon.png

File renamed without changes.

‎remoteshell/libs/kivy_console.py

+904
Large diffs are not rendered by default.
+311
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
"""TODO: ctrl + left/right (move past word), ctrl + backspace/del (del word), shift + del (del line)
2+
...: Smart movement through leading indentation.
3+
...: Except for first line, up/down to work normally on multi-line console input.
4+
"""
5+
from code import InteractiveConsole
6+
from collections import deque
7+
from dataclasses import dataclass
8+
from io import StringIO
9+
from itertools import chain, takewhile
10+
from more_itertools import ilen
11+
import sys
12+
from kivy.uix.codeinput import CodeInput
13+
from pygments.lexers import PythonConsoleLexer
14+
15+
16+
@dataclass(frozen=True)
17+
class Key:
18+
# ANY equals everything! -- if you don't care about matching modifiers, set them equal to Key.ANY
19+
ANY = type('ANY', (), { '__eq__': lambda *args: True,
20+
'__repr__': lambda self: 'ANY',
21+
'__hash__': lambda self: -1})()
22+
23+
code: int
24+
shift: bool = False
25+
ctrl: bool = False
26+
27+
def __eq__(self, other):
28+
if isinstance(other, int): return other == self.code
29+
return self.__dict__ == other.__dict__
30+
31+
def iter_similar(self):
32+
"""Return an iterator that yields keys equal to self."""
33+
yield self
34+
yield Key(self.code, self.shift, Key.ANY)
35+
yield Key(self.code, Key.ANY, self.ctrl)
36+
yield Key(self.code, Key.ANY, Key.ANY)
37+
38+
39+
SHIFT, CTRL = (303, 304), (305, 306)
40+
41+
EXACT = map(Key, (13, 9, 275, 276, 278, 279))
42+
ANY_MODS = (Key(code, Key.ANY, Key.ANY) for code in (273, 274, 8, 127))
43+
44+
KEYS \
45+
= ENTER, TAB, RIGHT, LEFT, HOME, END, UP, DOWN, BACKSPACE, DELETE \
46+
= tuple(chain(EXACT, ANY_MODS))
47+
48+
del EXACT; del ANY_MODS # Generators exhausted and we don't need them anymore
49+
50+
CUT = Key(120, False, True) # <ctrl + c>
51+
COPY = Key(99 , False, True) # <ctrl + x>
52+
REDO = Key(122, True, True) # <ctrl + shift + z>
53+
54+
SELECT_LEFT = Key(276, True, False) # <shift + left>
55+
SELECT_RIGHT = Key(275, True, False) # <shift + right>
56+
SELECT_HOME = Key(278, True, False) # <shift + home>
57+
SELECT_END = Key(279, True, False) # <shift + end>
58+
59+
60+
class RedirectConsoleOut:
61+
"""Redirect sys.excepthook and sys.stdout in a single context manager.
62+
InteractiveConsole (IC) `write` method won't be used if sys.excepthook isn't sys.__excepthook__,
63+
so we redirect sys.excepthook when pushing to the IC. This redirect probably isn't necessary:
64+
testing was done in IPython which sets sys.excepthook to a crashhandler, but running this file
65+
normally would probably avoid the need for a redirect; still, better safe than sorry.
66+
"""
67+
def __init__(self):
68+
self.stack = deque()
69+
70+
def __enter__(self):
71+
self.old_hook = sys.excepthook
72+
self.old_out = sys.stdout
73+
74+
sys.excepthook = sys.__excepthook__
75+
sys.stdout = StringIO()
76+
77+
sys.stdout.write('\n')
78+
79+
def __exit__(self, type, value, tb):
80+
self.stack.append(sys.stdout.getvalue())
81+
82+
sys.stdout = self.old_out
83+
sys.excepthook = self.old_hook
84+
85+
86+
class Console(InteractiveConsole):
87+
def __init__(self, text_input, locals=None, filename="<console>"):
88+
super().__init__(locals, filename)
89+
self.text_input = text_input
90+
self.out_context = RedirectConsoleOut()
91+
92+
def push(self, line):
93+
out = self.out_context
94+
with out: needs_more = super().push(line)
95+
96+
if not needs_more:
97+
out.stack.reverse()
98+
self.text_input.text += ''.join(out.stack)
99+
out.stack.clear()
100+
101+
return needs_more
102+
103+
def write(self, data):
104+
self.out_context.stack.append(data)
105+
106+
107+
class InputHandler:
108+
def __init__(self, text_input):
109+
self.text_input = text_input
110+
111+
self.pre = { COPY: self._copy,
112+
CUT: self._cut,
113+
REDO: self._redo}
114+
115+
self.post = { LEFT: self._left,
116+
RIGHT: self._right,
117+
END: self._end,
118+
HOME: self._home,
119+
SELECT_LEFT: self._select_left,
120+
SELECT_RIGHT: self._select_right,
121+
SELECT_END: self._select_end,
122+
SELECT_HOME: self._select_home,
123+
TAB: self._tab,
124+
ENTER: self._enter,
125+
UP: self._up,
126+
DOWN: self._down,
127+
BACKSPACE: self._backspace}
128+
129+
def __call__(self, key, read_only):
130+
if handle := self.pre.get(key): return handle
131+
132+
if read_only: return self._read_only
133+
134+
for key in key.iter_similar():
135+
if handle := self.post.get(key): return handle
136+
137+
def _copy(self, **kwargs): self.text_input.copy()
138+
139+
def _cut(self, read_only, **kwargs):
140+
self.text_input.copy() if read_only else self.text_input.cut()
141+
142+
def _redo(self, **kwargs): self.text_input.do_redo()
143+
144+
def _left(self, at_home, **kwargs):
145+
self.text_input.cancel_selection()
146+
if not at_home: self.text_input.move_cursor('left')
147+
148+
def _right(self, at_end, **kwargs):
149+
self.text_input.cancel_selection()
150+
if not at_end: self.text_input.move_cursor('right')
151+
152+
def _end(self, **kwargs):
153+
self.text_input.cancel_selection()
154+
self.text_input.move_cursor('end')
155+
156+
def _home(self, **kwargs):
157+
self.text_input.cancel_selection()
158+
self.text_input.move_cursor('home')
159+
160+
def _select_left(self, at_home, has_selection, _from, _to, **kwargs):
161+
if at_home: return
162+
i = self.text_input.move_cursor('left')
163+
if not has_selection: self.text_input.select_text(i, i + 1)
164+
elif i < _from : self.text_input.select_text(i, _to)
165+
elif i >= _from : self.text_input.select_text(_from, i)
166+
167+
def _select_right(self, at_end, has_selection, _from, _to, **kwargs):
168+
if at_end: return
169+
i = self.text_input.move_cursor('right')
170+
if not has_selection: self.text_input.select_text(i - 1, i)
171+
elif i > _to : self.text_input.select_text(_from, i)
172+
elif i <= _to : self.text_input.select_text(i, _to)
173+
174+
def _select_end(self, has_selection, _to, _from, i, end, **kwargs):
175+
if not has_selection: start = i
176+
elif _to == i : start = _from
177+
else : start = _to
178+
self.text_input.select_text(start, end)
179+
self.text_input.move_cursor('end')
180+
181+
def _select_home(self, has_selection, _to, _from, i, home, **kwargs):
182+
if not has_selection: fin = i
183+
elif _from == i : fin = _to
184+
else : fin = _from
185+
self.text_input.select_text(home, fin)
186+
self.text_input.move_cursor('home')
187+
188+
def _tab(self, has_selection, at_home, **kwargs):
189+
ti = self.text_input
190+
if not has_selection and at_home: ti.insert_text(' ' * ti.tab_width)
191+
192+
def _enter(self, home, **kwargs):
193+
ti = self.text_input
194+
text = ti.text[home:].rstrip()
195+
196+
if text and (len(ti.history) == 1 or ti.history[1] != text):
197+
ti.history.popleft()
198+
ti.history.appendleft(text)
199+
ti.history.appendleft('')
200+
ti._history_index = 0
201+
202+
needs_more = ti.console.push(text)
203+
ti.prompt(needs_more)
204+
205+
def _up(self, **kwargs): self.text_input.input_from_history()
206+
207+
def _down(self, **kwargs): self.text_input.input_from_history(reverse=True)
208+
209+
def _backspace(self, at_home, has_selection, window, keycode, text, modifiers, **kwargs):
210+
ti = self.text_input
211+
if not at_home or has_selection:
212+
super(KivyConsole, ti).keyboard_on_key_down(window, keycode, text, modifiers)
213+
214+
def _read_only(self, key, window, keycode, text, modifiers, **kwargs):
215+
ti = self.text_input
216+
ti.cancel_selection()
217+
ti.move_cursor('end')
218+
if key.code not in KEYS:
219+
super(KivyConsole, ti).keyboard_on_key_down(window, keycode, text, modifiers)
220+
221+
222+
class KivyConsole(CodeInput):
223+
prompt_1 = '\n>>> '
224+
prompt_2 = '\n... '
225+
226+
_home_pos = 0
227+
_indent_level = 0
228+
_history_index = 0
229+
230+
def __init__(self, *args, locals=None, banner=None, **kwargs):
231+
super().__init__(*args, **kwargs)
232+
self.lexer = PythonConsoleLexer()
233+
self.history = deque([''])
234+
self.console = Console(self, locals)
235+
self.input_handler = InputHandler(self)
236+
237+
if banner is None:
238+
self.text = (f'Python {sys.version.splitlines()[0]}\n'
239+
'Welcome to the KivyConsole -- A Python interpreter widget for Kivy!\n')
240+
else: self.text = banner
241+
self.prompt()
242+
243+
def prompt(self, needs_more=False):
244+
if needs_more:
245+
prompt = self.prompt_2
246+
self._indent_level = self.count_indents()
247+
if self.text.rstrip().endswith(':'): self._indent_level += 1
248+
else:
249+
prompt = self.prompt_1
250+
self._indent_level = 0
251+
252+
indent = self.tab_width * self._indent_level
253+
self.text += prompt + ' ' * indent
254+
self._home_pos = self.cursor_index() - indent
255+
self.reset_undo()
256+
257+
def count_indents(self):
258+
return ilen(takewhile(str.isspace, self.history[1])) // self.tab_width
259+
260+
def keyboard_on_key_down(self, window, keycode, text, modifiers):
261+
"""Emulate a python console: disallow editing of previous console output."""
262+
if keycode[0] in CTRL or keycode[0] in SHIFT and 'ctrl' in modifiers: return
263+
264+
key = Key(keycode[0], 'shift' in modifiers, 'ctrl' in modifiers)
265+
266+
# force `selection_from` <= `selection_to` (mouse selections can reverse the order):
267+
_from, _to = sorted((self.selection_from, self.selection_to))
268+
has_selection = bool(self.selection_text)
269+
i, home, end = self.cursor_index(), self._home_pos, len(self.text)
270+
271+
read_only = i < home or has_selection and _from < home
272+
at_home = i == home
273+
at_end = i == end
274+
275+
kwargs = locals(); del kwargs['self']
276+
if handle := self.input_handler(key, read_only): return handle(**kwargs)
277+
278+
return super().keyboard_on_key_down(window, keycode, text, modifiers)
279+
280+
def move_cursor(self, pos):
281+
"""Similar to `do_cursor_movement` but we account for `_home_pos` and we return the new cursor index."""
282+
if pos == 'end' : index = len(self.text)
283+
elif pos == 'home' : index = self._home_pos
284+
elif pos == 'left' : index = self.cursor_index() - 1
285+
elif pos == 'right': index = self.cursor_index() + 1
286+
self.cursor = self.get_cursor_from_index(index)
287+
return index
288+
289+
def input_from_history(self, reverse=False):
290+
self._history_index += -1 if reverse else 1
291+
self._history_index = min(max(0, self._history_index), len(self.history) - 1)
292+
self.text = self.text[: self._home_pos] + self.history[self._history_index]
293+
294+
295+
if __name__ == "__main__":
296+
from textwrap import dedent
297+
from kivy.app import App
298+
from kivy.lang import Builder
299+
300+
KV = """
301+
KivyConsole:
302+
font_name : './UbuntuMono-R.ttf'
303+
style_name: 'monokai'
304+
"""
305+
306+
307+
class KivyInterpreter(App):
308+
def build(self): return Builder.load_string(dedent(KV))
309+
310+
311+
KivyInterpreter().run()

‎libs/garden/garden.navigationdrawer/__init__.py ‎remoteshell/libs/navigationdrawer/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ def set_main_panel(self, widget):
445445
# Clear existing side panel entries
446446
if len(self._main_panel.children) > 0:
447447
for child in self._main_panel.children:
448-
self._main_panel.remove(child)
448+
self._main_panel.remove_widget(child)
449449
# Set new side panel
450450
self._main_panel.add_widget(widget)
451451
self.main_panel = widget
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = '1.0.2'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pytest
2+
3+
4+
def test_flower():
5+
from kivy_garden.navigationdrawer import NavigationDrawer
6+
widget = NavigationDrawer()

‎main.py ‎remoteshell/main.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
from kivy.properties import StringProperty
1717
from kivy.app import App
1818

19-
from kivy.garden import navigationdrawer
19+
from libs.navigationdrawer import NavigationDrawer
2020
from kivy.uix.screenmanager import Screen
21+
from kivy.core.window import Window
22+
Window.softinput_mode = 'below_target'
2123
app = None
2224

2325
#+---[RSA 3072]----+
@@ -120,7 +122,10 @@ class MainScreen(Screen):
120122
def __init__(self, **kwargs):
121123
super(MainScreen, self).__init__(**kwargs)
122124

123-
ip = socket.gethostbyname(socket.gethostname())
125+
try:
126+
ip = socket.gethostbyname(socket.gethostname())
127+
except Exception as Error:
128+
ip = socket.gethostbyname('localhost')
124129
if ip.startswith('127.'):
125130
interfaces = ['eth0', 'eth1', 'eth2', 'wlan0', 'wlan1', 'wifi0',
126131
'tiwlan0', 'tiwlan1', 'ath0', 'ath1', 'ppp0']

‎plyer_command_list.rst ‎remoteshell/plyer_command_list.rst

+1-29
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Command list
1717
- ACCELERATOR_
1818
- GYROSCOPE_
1919
- CALL_
20-
20+
2121
TTS(Text to speech)
2222
-------------------
2323

@@ -28,33 +28,6 @@ Example::
2828

2929
top_
3030

31-
GPS
32-
---
33-
34-
.. _GPS:
35-
36-
.. note::
37-
38-
This will work only on versions before android 6.0 .
39-
40-
For android 6.0 + the coder needs to explictly ask permissions.
41-
42-
43-
Here is an example of the usage of gps::
44-
45-
from plyer import gps
46-
coordinate = 0
47-
def print_locations(**kwargs):
48-
global coordinate
49-
coordinate = kwargs
50-
51-
gps.configure(on_location=print_locations)
52-
gps.start()
53-
# later
54-
print coordinate
55-
gps.stop()
56-
57-
5831
Notification
5932
------------
6033

@@ -207,4 +180,3 @@ IrBlaster
207180
Example::
208181

209182
from plyer import irblaster
210-

‎remotekivy.kv ‎remoteshell/remotekivy.kv

+30-8
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@
1111
text: open('plyer_command_list.rst').read()
1212

1313
<ShellScreen@Screen>
14+
#KivyConsole
1415

1516
<NavigationButton@Button>
17+
opacity: 1
1618
on_release: app.root.ids.manager.current = self.text.lower()
19+
size_hint_y: None
20+
height: sp(64)
1721

1822
<NavigationScreen@BoxLayout>
1923
orientation: 'vertical'
@@ -23,9 +27,13 @@
2327
text: 'Plyer'
2428
NavigationButton
2529
text: 'Console'
30+
NavigationButton
31+
text: 'Python Interpretter'
32+
Widget
2633

2734
<TopBar@BoxLayout>
28-
size_hint_y: .2
35+
size_hint_y: None
36+
height: sp(64)
2937
pos_hint: {'top': 1}
3038
canvas:
3139
Color:
@@ -37,13 +45,13 @@
3745
size_hint_x: None
3846
width: self.height
3947
border: 0, 0, 0, 0
40-
background_normal: 'ham_icon.png'
41-
background_down: 'ham_icon.png'
48+
background_normal: 'data/ham_icon.png'
49+
background_down: 'data/ham_icon.png'
4250
opacity: 1 if self.state == 'normal' else .5
4351
on_state: app.root.toggle_state()
4452
Widget
4553
Image:
46-
source: 'icon.png'
54+
source: 'data/icon.png'
4755
mipmap: True
4856
size_hint_x: None
4957
width: '100dp'
@@ -59,7 +67,7 @@ NavigationDrawer
5967
NavigationScreen
6068
Screen
6169
Image:
62-
source: 'background.png'
70+
source: 'data/background.png'
6371
allow_stretch: True
6472
keep_ratio: False
6573
BoxLayout
@@ -69,8 +77,22 @@ NavigationDrawer
6977
id: manager
7078
MainScreen
7179
name: 'remote'
72-
# CommandScreen
73-
# name: 'plyer'
80+
CommandScreen
81+
name: 'plyer'
7482
ShellScreen
7583
name: 'console'
76-
84+
on_enter:
85+
if not self.children:\
86+
from libs.kivy_console import KivyConsole as ShellConsole;\
87+
sc = ShellConsole();\
88+
sc.font_name = 'RobotoMono-Regular.ttf';\
89+
self.add_widget(sc)
90+
Screen
91+
name: 'python interpretter'
92+
on_enter:
93+
if not self.children:\
94+
from libs.kivy_python_console import KivyConsole as PythonConsole;\
95+
pc = PythonConsole();\
96+
pc.font_name = 'RobotoMono-Regular.ttf';\
97+
pc.style_name = 'monokai';\
98+
self.add_widget(pc)

‎test.py ‎tests/test.py

File renamed without changes.

‎tools/build/build-android.sh

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env bash
2+
set -euxo pipefail
3+
4+
ROOT=$(realpath $(dirname "$0")/../..)
5+
6+
cd "$ROOT/tools/build"
7+
8+
BUILDOZER_BIN_DIR="$ROOT/.buildozer/bin" BUILDOZER_BUILD_DIR="${ROOT}"/.buildozer buildozer android debug deploy run logcat

‎buildozer.spec ‎tools/build/buildozer.spec

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[app]
22

3+
icon=%(source.dir)s/data/icon.png
34
# title of the application
45
title = Kivy Remote Shell
56

@@ -10,15 +11,15 @@ package.name = remoteshell
1011
package.domain = org.kivy
1112

1213
# indicate where the source code is living
13-
source.dir = .
14+
source.dir = ../../remoteshell
1415
source.include_exts = py,png,kv,rst
1516

1617
# search the version information into the source code
1718
version.regex = __version__ = '(.*)'
1819
version.filename = %(source.dir)s/main.py
1920

2021
# requirements of the app
21-
requirements = android,cryptography,pyasn1,bcrypt,attrs,twisted,kivy,docutils,pygments,cffi
22+
requirements = android,cryptography,pyasn1,bcrypt,attrs,twisted,kivy,docutils,pygments,cffi, more_itertools,plyer
2223

2324
# android specific
2425
android.permissions = INTERNET, WAKE_LOCK, CAMERA, VIBRATE, ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION, SEND_SMS, CALL_PRIVILEGED, CALL_PHONE, BLUETOOTH
@@ -27,13 +28,20 @@ android.permissions = INTERNET, WAKE_LOCK, CAMERA, VIBRATE, ACCESS_COARSE_LOCATI
2728

2829
#android.api=22
2930
android.accept_sdk_license=True
31+
32+
# (str) Android logcat filters to use
33+
android.logcat_filters = *:S python,mediaserver,SDL:D
3034
android.wakelock=True
35+
3136
orientation=portrait
3237
fullscreen=True
3338
p4a.branch = develop
39+
p4a.local_recipes = p4a_recipes
3440

35-
#presplash.filename=
41+
#presplash.filename=
3642

3743
[buildozer]
3844
log_level = 2
3945
warn_on_root = 1
46+
#bin_dir = ../..//buildozer/bin
47+
#build_dir = ../../.buildozer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pythonforandroid.recipe import CompiledComponentsPythonRecipe, Recipe
2+
3+
4+
class CryptographyRecipe(CompiledComponentsPythonRecipe):
5+
name = 'cryptography'
6+
version = '2.8'
7+
url = 'https://github.com/pyca/cryptography/archive/{version}.tar.gz'
8+
depends = ['openssl', 'six', 'setuptools', 'cffi']
9+
call_hostpython_via_targetpython = False
10+
11+
def get_recipe_env(self, arch):
12+
env = super().get_recipe_env(arch)
13+
14+
openssl_recipe = Recipe.get_recipe('openssl', self.ctx)
15+
env['CFLAGS'] += openssl_recipe.include_flags(arch)
16+
env['LDFLAGS'] += openssl_recipe.link_dirs_flags(arch)
17+
env['LIBS'] = openssl_recipe.link_libs_flags()
18+
19+
return env
20+
21+
22+
recipe = CryptographyRecipe()

‎requirements.txt ‎tools/build/requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ pycrypto==2.6.1
66
requests==2.32.2
77
Twisted==24.7.0
88
#zope.interface==4.3.2
9+
more_itertools==2.2
10+
plyer==master

0 commit comments

Comments
 (0)
Please sign in to comment.