pymobile-framework 0.1.0__py3-none-any.whl
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.
- pymobile/__init__.py +109 -0
- pymobile/__main__.py +12 -0
- pymobile/cli.py +417 -0
- pymobile/compiler/__init__.py +26 -0
- pymobile/compiler/backends/__init__.py +11 -0
- pymobile/compiler/backends/native.py +541 -0
- pymobile/compiler/cache.py +89 -0
- pymobile/compiler/collector.py +106 -0
- pymobile/compiler/icon.py +119 -0
- pymobile/compiler/manifest.py +104 -0
- pymobile/compiler/packager.py +107 -0
- pymobile/compiler/pipeline.py +380 -0
- pymobile/compiler/runtime.py +90 -0
- pymobile/compiler/scaffold.py +87 -0
- pymobile/compiler/sdk_installer.py +189 -0
- pymobile/compiler/toolchain.py +251 -0
- pymobile/core/__init__.py +68 -0
- pymobile/core/api/__init__.py +9 -0
- pymobile/core/api/notifications.py +84 -0
- pymobile/core/api/permissions.py +137 -0
- pymobile/core/api/vibration.py +69 -0
- pymobile/core/app.py +197 -0
- pymobile/core/bridge/__init__.py +60 -0
- pymobile/core/bridge/android.py +116 -0
- pymobile/core/bridge/base.py +96 -0
- pymobile/core/bridge/jni.py +196 -0
- pymobile/core/bridge/stub.py +97 -0
- pymobile/core/config.py +226 -0
- pymobile/core/events.py +101 -0
- pymobile/core/net/__init__.py +7 -0
- pymobile/core/net/http.py +257 -0
- pymobile/core/platform.py +55 -0
- pymobile/core/ui/__init__.py +31 -0
- pymobile/core/ui/components.py +235 -0
- pymobile/core/ui/layout.py +72 -0
- pymobile/core/ui/screen.py +175 -0
- pymobile/core/ui/style.py +121 -0
- pymobile/core/ui/widget.py +148 -0
- pymobile/errors.py +59 -0
- pymobile/logging.py +80 -0
- pymobile/py.typed +0 -0
- pymobile/resources/__init__.py +52 -0
- pymobile/resources/android/java/DeviceServices.java +181 -0
- pymobile/resources/android/java/MainActivity.java +203 -0
- pymobile/resources/android/java/Native.java +73 -0
- pymobile/resources/android/java/PythonRuntime.java +103 -0
- pymobile/resources/android/java/ViewBuilder.java +485 -0
- pymobile/resources/android/jni/pymobile_jni.c +593 -0
- pymobile/resources/android/prebuilt/arm64-v8a/classes.dex +0 -0
- pymobile/resources/android/prebuilt/arm64-v8a/libpymobile.so +0 -0
- pymobile/resources/icons/default_icon.png +0 -0
- pymobile/resources/templates/README.md.template +24 -0
- pymobile/resources/templates/gitignore.template +10 -0
- pymobile/resources/templates/main.py.template +58 -0
- pymobile/resources/templates/pymobile.toml.template +42 -0
- pymobile/tests/__init__.py +1 -0
- pymobile/tests/conftest.py +32 -0
- pymobile/tests/test_android_api.py +141 -0
- pymobile/tests/test_cli.py +267 -0
- pymobile/tests/test_compiler.py +453 -0
- pymobile/tests/test_config.py +155 -0
- pymobile/tests/test_core.py +171 -0
- pymobile/tests/test_http.py +205 -0
- pymobile/tests/test_native.py +1006 -0
- pymobile/tests/test_ui.py +284 -0
- pymobile-demo/main.py +840 -0
- pymobile_framework-0.1.0.dist-info/METADATA +1019 -0
- pymobile_framework-0.1.0.dist-info/RECORD +72 -0
- pymobile_framework-0.1.0.dist-info/WHEEL +5 -0
- pymobile_framework-0.1.0.dist-info/entry_points.txt +2 -0
- pymobile_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymobile_framework-0.1.0.dist-info/top_level.txt +2 -0
pymobile/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""PyMobile — a Python framework for building Android applications.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from pymobile import App, Column, Label, Button, Screen
|
|
6
|
+
|
|
7
|
+
class Home(Screen):
|
|
8
|
+
def build(self):
|
|
9
|
+
return Column(
|
|
10
|
+
Label("Hello, Android!"),
|
|
11
|
+
Button("Tap", on_press=lambda: print("tapped")),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
App("Demo").run(Home())
|
|
15
|
+
|
|
16
|
+
Build it with ``pymobile build``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__author__ = "PyMobile Contributors"
|
|
23
|
+
__license__ = "MIT"
|
|
24
|
+
|
|
25
|
+
from .core.api import Notifications, Permission, PermissionManager, Vibration
|
|
26
|
+
from .core.app import App
|
|
27
|
+
from .core.config import ProjectConfig, load_config
|
|
28
|
+
from .core.events import Event, EventBus
|
|
29
|
+
from .core.net import HttpClient, Response
|
|
30
|
+
from .core.platform import Platform, current_platform, is_android, is_desktop
|
|
31
|
+
from .core.ui import (
|
|
32
|
+
Align,
|
|
33
|
+
Button,
|
|
34
|
+
Color,
|
|
35
|
+
Column,
|
|
36
|
+
Container,
|
|
37
|
+
EdgeInsets,
|
|
38
|
+
Image,
|
|
39
|
+
Label,
|
|
40
|
+
Navigator,
|
|
41
|
+
ProgressBar,
|
|
42
|
+
Row,
|
|
43
|
+
Screen,
|
|
44
|
+
ScrollView,
|
|
45
|
+
Spacer,
|
|
46
|
+
Stack,
|
|
47
|
+
Style,
|
|
48
|
+
Switch,
|
|
49
|
+
TextInput,
|
|
50
|
+
Widget,
|
|
51
|
+
)
|
|
52
|
+
from .errors import (
|
|
53
|
+
BridgeError,
|
|
54
|
+
ConfigError,
|
|
55
|
+
NetworkError,
|
|
56
|
+
PlatformError,
|
|
57
|
+
PyMobileError,
|
|
58
|
+
ResourceError,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"__version__",
|
|
63
|
+
# application
|
|
64
|
+
"App",
|
|
65
|
+
"ProjectConfig",
|
|
66
|
+
"load_config",
|
|
67
|
+
"Event",
|
|
68
|
+
"EventBus",
|
|
69
|
+
# platform
|
|
70
|
+
"Platform",
|
|
71
|
+
"current_platform",
|
|
72
|
+
"is_android",
|
|
73
|
+
"is_desktop",
|
|
74
|
+
# android apis
|
|
75
|
+
"Notifications",
|
|
76
|
+
"Vibration",
|
|
77
|
+
"Permission",
|
|
78
|
+
"PermissionManager",
|
|
79
|
+
# networking
|
|
80
|
+
"HttpClient",
|
|
81
|
+
"Response",
|
|
82
|
+
# ui
|
|
83
|
+
"Widget",
|
|
84
|
+
"Container",
|
|
85
|
+
"Label",
|
|
86
|
+
"Button",
|
|
87
|
+
"TextInput",
|
|
88
|
+
"Image",
|
|
89
|
+
"Switch",
|
|
90
|
+
"ProgressBar",
|
|
91
|
+
"Spacer",
|
|
92
|
+
"Column",
|
|
93
|
+
"Row",
|
|
94
|
+
"ScrollView",
|
|
95
|
+
"Stack",
|
|
96
|
+
"Screen",
|
|
97
|
+
"Navigator",
|
|
98
|
+
"Style",
|
|
99
|
+
"Color",
|
|
100
|
+
"Align",
|
|
101
|
+
"EdgeInsets",
|
|
102
|
+
# errors
|
|
103
|
+
"PyMobileError",
|
|
104
|
+
"ConfigError",
|
|
105
|
+
"BridgeError",
|
|
106
|
+
"PlatformError",
|
|
107
|
+
"NetworkError",
|
|
108
|
+
"ResourceError",
|
|
109
|
+
]
|
pymobile/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Allow ``python -m pymobile`` as an alias for the ``pymobile`` command.
|
|
2
|
+
|
|
3
|
+
Useful when pip's scripts directory is not on ``PATH`` — a very common
|
|
4
|
+
situation on Windows — because the module form always works.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .cli import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
raise SystemExit(main())
|
pymobile/cli.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Sub-commands: ``init``, ``build``, ``run``, ``info``, ``clean``, ``doctor``.
|
|
4
|
+
Every command returns an exit code; :func:`main` is the console-script entry
|
|
5
|
+
point declared in ``pyproject.toml``.
|
|
6
|
+
|
|
7
|
+
Errors are printed as a short message plus an actionable hint — tracebacks only
|
|
8
|
+
appear with ``--verbose``, because a build tool that dumps a stack trace at a
|
|
9
|
+
user who typed a wrong package name is not helpful.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .compiler.pipeline import BuildPipeline
|
|
25
|
+
from .compiler.scaffold import create_project
|
|
26
|
+
from .core.config import CONFIG_FILENAME, ProjectConfig, load_config
|
|
27
|
+
from .errors import PyMobileError
|
|
28
|
+
from .logging import configure, get_logger, supports_color
|
|
29
|
+
|
|
30
|
+
__all__ = ["main", "build_parser"]
|
|
31
|
+
|
|
32
|
+
_log = get_logger("cli")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# output helpers
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
class _Out:
|
|
39
|
+
"""Minimal styled console output."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self.color = supports_color(sys.stdout)
|
|
43
|
+
|
|
44
|
+
def _paint(self, text: str, code: str) -> str:
|
|
45
|
+
return f"\033[{code}m{text}\033[0m" if self.color else text
|
|
46
|
+
|
|
47
|
+
def ok(self, message: str) -> None:
|
|
48
|
+
# stdout is block-buffered when piped while stderr is not, so progress
|
|
49
|
+
# lines are flushed to keep them in order with warnings and errors.
|
|
50
|
+
print(self._paint("✓", "32") + f" {message}", flush=True)
|
|
51
|
+
|
|
52
|
+
def info(self, message: str) -> None:
|
|
53
|
+
print(self._paint("•", "36") + f" {message}", flush=True)
|
|
54
|
+
|
|
55
|
+
def warn(self, message: str) -> None:
|
|
56
|
+
print(self._paint("!", "33") + f" {message}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
def error(self, message: str) -> None:
|
|
59
|
+
print(self._paint("✗", "31") + f" {message}", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
def hint(self, message: str) -> None:
|
|
62
|
+
print(f" {self._paint('hint:', '2;37')} {message}", file=sys.stderr)
|
|
63
|
+
|
|
64
|
+
def field(self, label: str, value: object) -> None:
|
|
65
|
+
print(f" {label:<14} {value}", flush=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_out = _Out()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _invocation() -> str:
|
|
72
|
+
"""How the user launched us: ``pymobile`` or ``python -m pymobile``.
|
|
73
|
+
|
|
74
|
+
pip's Scripts directory is frequently missing from PATH on Windows, so
|
|
75
|
+
echoing back a bare ``pymobile ...`` would print a command that does not
|
|
76
|
+
work for that user.
|
|
77
|
+
"""
|
|
78
|
+
launched_as_module = Path(sys.argv[0]).name in ("__main__.py", "cli.py")
|
|
79
|
+
if launched_as_module:
|
|
80
|
+
return f"{Path(sys.executable).name} -m pymobile"
|
|
81
|
+
return "pymobile"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _export_command(name: str, value: object) -> str:
|
|
85
|
+
"""Render an environment-variable assignment for the host shell."""
|
|
86
|
+
if platform.system() == "Windows":
|
|
87
|
+
return f'$env:{name} = "{value}"'
|
|
88
|
+
return f"export {name}={value}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# commands
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
95
|
+
"""Create a new project."""
|
|
96
|
+
directory = Path(args.directory).resolve()
|
|
97
|
+
name = args.name or directory.name.replace("-", " ").replace("_", " ").title()
|
|
98
|
+
result = create_project(directory, name, package=args.package, force=args.force)
|
|
99
|
+
_out.ok(f"created project {name!r} in {result.directory}")
|
|
100
|
+
for path in result.files:
|
|
101
|
+
_out.field("", path.relative_to(result.directory))
|
|
102
|
+
print()
|
|
103
|
+
_out.info(f"cd {result.directory.name} && {_invocation()} build --native")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
108
|
+
"""Compile the project into an APK."""
|
|
109
|
+
config = _load(args)
|
|
110
|
+
if args.icon:
|
|
111
|
+
config.icon = args.icon
|
|
112
|
+
if args.output:
|
|
113
|
+
config.output_dir = args.output
|
|
114
|
+
if args.no_optimize:
|
|
115
|
+
config.optimize = False
|
|
116
|
+
config.validate()
|
|
117
|
+
|
|
118
|
+
if args.clean:
|
|
119
|
+
_clean(config)
|
|
120
|
+
|
|
121
|
+
native = getattr(args, "native", False)
|
|
122
|
+
if native:
|
|
123
|
+
_out.info("native build: this may take a few minutes on the first run")
|
|
124
|
+
pipeline = BuildPipeline(
|
|
125
|
+
config,
|
|
126
|
+
use_cache=not args.no_cache and not args.clean,
|
|
127
|
+
native=native,
|
|
128
|
+
on_stage=lambda stage: _out.info(f"{stage}…") if args.verbose else None,
|
|
129
|
+
)
|
|
130
|
+
result = pipeline.run()
|
|
131
|
+
|
|
132
|
+
for warning in result.warnings:
|
|
133
|
+
_out.warn(warning)
|
|
134
|
+
|
|
135
|
+
if result.cached:
|
|
136
|
+
size = (
|
|
137
|
+
f"{result.size / (1024 * 1024):.1f} MB"
|
|
138
|
+
if result.size >= 1024 * 1024
|
|
139
|
+
else f"{result.size_kb:.1f} KB"
|
|
140
|
+
)
|
|
141
|
+
_out.ok(f"up to date: {result.apk.name} ({size})")
|
|
142
|
+
_out.hint("use --clean to force a full rebuild")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
_out.ok(result.summary())
|
|
146
|
+
_out.field("apk", result.apk)
|
|
147
|
+
_out.field("icon", "default" if result.icon_is_default else config.icon)
|
|
148
|
+
if result.native:
|
|
149
|
+
_out.field("install", f"adb install -r {result.apk.name}")
|
|
150
|
+
if args.verbose:
|
|
151
|
+
for timing in result.timings:
|
|
152
|
+
_out.field(timing.name, f"{timing.seconds * 1000:.0f} ms")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
157
|
+
"""Import the entry point and render the first screen on the desktop."""
|
|
158
|
+
config = _load(args)
|
|
159
|
+
entry = config.entrypoint_path
|
|
160
|
+
if not entry.exists():
|
|
161
|
+
raise PyMobileError(
|
|
162
|
+
f"Entry point not found: {entry}",
|
|
163
|
+
hint="Check `entrypoint` in your configuration.",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
_out.info(f"running {entry.name} in desktop preview mode")
|
|
167
|
+
sys.path.insert(0, str(config.source_path))
|
|
168
|
+
namespace: dict[str, object] = {"__name__": "__main__", "__file__": str(entry)}
|
|
169
|
+
exec(compile(entry.read_text(encoding="utf-8"), str(entry), "exec"), namespace)
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
174
|
+
"""Print the resolved configuration."""
|
|
175
|
+
config = _load(args)
|
|
176
|
+
if args.json:
|
|
177
|
+
print(json.dumps(config.to_dict(), indent=2, sort_keys=True))
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
_out.info(f"{config.name} {config.version} ({config.package})")
|
|
181
|
+
_out.field("entrypoint", config.entrypoint_path)
|
|
182
|
+
_out.field("source", config.source_path)
|
|
183
|
+
_out.field("output", config.output_path)
|
|
184
|
+
_out.field("apk", config.apk_name)
|
|
185
|
+
_out.field("sdk", f"min {config.min_sdk} / target {config.target_sdk}")
|
|
186
|
+
_out.field("abis", ", ".join(config.abis))
|
|
187
|
+
_out.field("icon", config.icon or "default")
|
|
188
|
+
_out.field("optimize", config.optimize)
|
|
189
|
+
print(" permissions")
|
|
190
|
+
for permission in sorted(config.permissions):
|
|
191
|
+
_out.field("", permission)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_clean(args: argparse.Namespace) -> int:
|
|
196
|
+
"""Remove build artifacts."""
|
|
197
|
+
config = _load(args)
|
|
198
|
+
removed = _clean(config)
|
|
199
|
+
_out.ok(f"removed {removed}" if removed else "nothing to clean")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_setup_sdk(args: argparse.Namespace) -> int:
|
|
204
|
+
"""Download and install the Android toolchain."""
|
|
205
|
+
from .compiler.sdk_installer import default_sdk_home, install_sdk
|
|
206
|
+
|
|
207
|
+
with_ndk = getattr(args, "with_ndk", False)
|
|
208
|
+
size = "~2.7 GB" if with_ndk else "~800 MB"
|
|
209
|
+
_out.info(f"installing the Android toolchain ({size}, one time)")
|
|
210
|
+
if not with_ndk:
|
|
211
|
+
_out.info("using the prebuilt native bridge; pass --with-ndk to build it from source")
|
|
212
|
+
sdk = install_sdk(Path(args.path) if args.path else None, with_ndk=with_ndk)
|
|
213
|
+
_out.ok(f"toolchain ready: {sdk}")
|
|
214
|
+
_out.info(f"now run: {_invocation()} build --native")
|
|
215
|
+
if not os.environ.get("ANDROID_HOME"):
|
|
216
|
+
_out.hint(f"optional: {_export_command('ANDROID_HOME', sdk)}")
|
|
217
|
+
_ = default_sdk_home
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
222
|
+
"""Check the environment and configuration."""
|
|
223
|
+
problems = 0
|
|
224
|
+
_out.info(f"pymobile {__version__} on Python {sys.version.split()[0]}")
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
import PIL # noqa: F401
|
|
228
|
+
|
|
229
|
+
_out.ok("Pillow available — icons will be resized for every density")
|
|
230
|
+
except ImportError:
|
|
231
|
+
_out.warn("Pillow missing — icons are copied without resizing")
|
|
232
|
+
_out.hint("pip install Pillow")
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
config = _load(args)
|
|
236
|
+
except PyMobileError as exc:
|
|
237
|
+
_out.warn(f"no usable project here: {exc}")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
_out.ok(f"configuration valid: {config.name} ({config.package})")
|
|
241
|
+
if not config.entrypoint_path.exists():
|
|
242
|
+
_out.error(f"entry point missing: {config.entrypoint_path}")
|
|
243
|
+
problems += 1
|
|
244
|
+
else:
|
|
245
|
+
_out.ok(f"entry point found: {config.entrypoint}")
|
|
246
|
+
|
|
247
|
+
icon = config.icon_path
|
|
248
|
+
if icon is not None and not icon.exists():
|
|
249
|
+
_out.error(f"icon missing: {icon}")
|
|
250
|
+
problems += 1
|
|
251
|
+
elif icon is not None:
|
|
252
|
+
_out.ok(f"custom icon: {icon.name}")
|
|
253
|
+
else:
|
|
254
|
+
_out.ok("using the default icon")
|
|
255
|
+
|
|
256
|
+
from .compiler.toolchain import ToolchainError, find_toolchain
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
toolchain = find_toolchain()
|
|
260
|
+
toolchain.verify()
|
|
261
|
+
_out.ok(f"Android toolchain ready (build-tools {toolchain.build_tools.name})")
|
|
262
|
+
if toolchain.has_ndk:
|
|
263
|
+
_out.ok("NDK present — the native bridge can be rebuilt from source")
|
|
264
|
+
else:
|
|
265
|
+
_out.ok("using the prebuilt native bridge (no NDK needed)")
|
|
266
|
+
_out.info(f"native APK builds available: {_invocation()} build --native")
|
|
267
|
+
except (ToolchainError, PyMobileError) as exc:
|
|
268
|
+
_out.warn(f"Android SDK not usable — only structural builds are available: {exc}")
|
|
269
|
+
_out.hint("run `pymobile setup-sdk` to install it automatically")
|
|
270
|
+
|
|
271
|
+
if problems:
|
|
272
|
+
_out.error(f"{problems} problem(s) found")
|
|
273
|
+
return 1
|
|
274
|
+
_out.ok("everything looks good")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# helpers
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
def _load(args: argparse.Namespace) -> ProjectConfig:
|
|
282
|
+
"""Load the project configuration honouring ``--config``."""
|
|
283
|
+
return load_config(getattr(args, "config", None) or Path.cwd())
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _clean(config: ProjectConfig) -> str:
|
|
287
|
+
"""Delete the output directory; returns what was removed."""
|
|
288
|
+
output = config.output_path
|
|
289
|
+
if output.exists():
|
|
290
|
+
shutil.rmtree(output)
|
|
291
|
+
return str(output)
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# parser
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
299
|
+
"""Construct the argument parser.
|
|
300
|
+
|
|
301
|
+
``--verbose`` and ``--config`` are attached both globally and to every
|
|
302
|
+
sub-command, so ``pymobile -v build`` and ``pymobile build -v`` both work —
|
|
303
|
+
users should not have to remember where a flag belongs.
|
|
304
|
+
"""
|
|
305
|
+
# SUPPRESS keeps unset flags out of the namespace, so a sub-command does
|
|
306
|
+
# not silently reset a value given before the sub-command name.
|
|
307
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
308
|
+
common.add_argument(
|
|
309
|
+
"-v",
|
|
310
|
+
"--verbose",
|
|
311
|
+
action="store_true",
|
|
312
|
+
default=argparse.SUPPRESS,
|
|
313
|
+
help="show detailed output",
|
|
314
|
+
)
|
|
315
|
+
common.add_argument(
|
|
316
|
+
"-c",
|
|
317
|
+
"--config",
|
|
318
|
+
metavar="PATH",
|
|
319
|
+
default=argparse.SUPPRESS,
|
|
320
|
+
help=f"path to {CONFIG_FILENAME} or a project directory",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
parser = argparse.ArgumentParser(
|
|
324
|
+
prog="pymobile",
|
|
325
|
+
description="Build Android applications with Python.",
|
|
326
|
+
epilog="Docs: https://github.com/pymobile/pymobile",
|
|
327
|
+
parents=[common],
|
|
328
|
+
)
|
|
329
|
+
parser.add_argument("--version", action="version", version=f"pymobile {__version__}")
|
|
330
|
+
|
|
331
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
332
|
+
|
|
333
|
+
init = sub.add_parser("init", help="create a new project", parents=[common])
|
|
334
|
+
init.add_argument("directory", nargs="?", default=".", help="target directory")
|
|
335
|
+
init.add_argument("-n", "--name", help="application name")
|
|
336
|
+
init.add_argument("-p", "--package", help="package id, e.g. com.example.app")
|
|
337
|
+
init.add_argument("-f", "--force", action="store_true", help="write into a non-empty directory")
|
|
338
|
+
init.set_defaults(func=cmd_init)
|
|
339
|
+
|
|
340
|
+
build = sub.add_parser("build", help="compile the project into an APK", parents=[common])
|
|
341
|
+
build.add_argument("-o", "--output", help="output directory")
|
|
342
|
+
build.add_argument("-i", "--icon", help="path to a custom launcher icon")
|
|
343
|
+
build.add_argument(
|
|
344
|
+
"--native",
|
|
345
|
+
action="store_true",
|
|
346
|
+
help="build a real, signed, installable APK (needs the Android SDK/NDK)",
|
|
347
|
+
)
|
|
348
|
+
build.add_argument("--clean", action="store_true", help="rebuild from scratch")
|
|
349
|
+
build.add_argument("--no-cache", action="store_true", help="ignore the incremental cache")
|
|
350
|
+
build.add_argument(
|
|
351
|
+
"--no-optimize", action="store_true", help="ship sources instead of bytecode"
|
|
352
|
+
)
|
|
353
|
+
build.set_defaults(func=cmd_build)
|
|
354
|
+
|
|
355
|
+
run = sub.add_parser("run", help="preview the app on this machine", parents=[common])
|
|
356
|
+
run.set_defaults(func=cmd_run)
|
|
357
|
+
|
|
358
|
+
info = sub.add_parser("info", help="show the resolved configuration", parents=[common])
|
|
359
|
+
info.add_argument("--json", action="store_true", help="machine-readable output")
|
|
360
|
+
info.set_defaults(func=cmd_info)
|
|
361
|
+
|
|
362
|
+
clean = sub.add_parser("clean", help="remove build artifacts", parents=[common])
|
|
363
|
+
clean.set_defaults(func=cmd_clean)
|
|
364
|
+
|
|
365
|
+
setup = sub.add_parser(
|
|
366
|
+
"setup-sdk", help="download the Android SDK/NDK for native builds", parents=[common]
|
|
367
|
+
)
|
|
368
|
+
setup.add_argument("--path", help="install directory (default: ~/.andro)")
|
|
369
|
+
setup.add_argument(
|
|
370
|
+
"--with-ndk",
|
|
371
|
+
action="store_true",
|
|
372
|
+
help="also download the NDK (~2 GB), only needed to rebuild the native bridge",
|
|
373
|
+
)
|
|
374
|
+
setup.set_defaults(func=cmd_setup_sdk)
|
|
375
|
+
|
|
376
|
+
doctor = sub.add_parser("doctor", help="check the environment", parents=[common])
|
|
377
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
378
|
+
|
|
379
|
+
return parser
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
383
|
+
"""CLI entry point; returns a process exit code."""
|
|
384
|
+
parser = build_parser()
|
|
385
|
+
args = parser.parse_args(argv)
|
|
386
|
+
# restore the defaults suppressed above
|
|
387
|
+
args.verbose = getattr(args, "verbose", False)
|
|
388
|
+
args.config = getattr(args, "config", None)
|
|
389
|
+
|
|
390
|
+
if not getattr(args, "command", None):
|
|
391
|
+
parser.print_help()
|
|
392
|
+
return 0
|
|
393
|
+
|
|
394
|
+
configure("debug" if args.verbose else "warning")
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
return int(args.func(args))
|
|
398
|
+
except PyMobileError as exc:
|
|
399
|
+
_out.error(str(exc))
|
|
400
|
+
if exc.hint:
|
|
401
|
+
_out.hint(exc.hint)
|
|
402
|
+
if args.verbose:
|
|
403
|
+
raise
|
|
404
|
+
return 1
|
|
405
|
+
except KeyboardInterrupt: # pragma: no cover - interactive
|
|
406
|
+
_out.warn("interrupted")
|
|
407
|
+
return 130
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
_out.error(f"unexpected error: {exc}")
|
|
410
|
+
if args.verbose:
|
|
411
|
+
raise
|
|
412
|
+
_out.hint("re-run with --verbose to see the full traceback")
|
|
413
|
+
return 1
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__": # pragma: no cover
|
|
417
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Compiler: turns a Python project into an Android APK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .cache import BuildCache
|
|
6
|
+
from .collector import SourceSet, collect_sources
|
|
7
|
+
from .icon import IconSet, prepare_icons
|
|
8
|
+
from .manifest import build_manifest
|
|
9
|
+
from .packager import ApkPackager, PackageResult
|
|
10
|
+
from .pipeline import BuildPipeline, BuildResult, build_apk
|
|
11
|
+
from .scaffold import create_project
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BuildPipeline",
|
|
15
|
+
"BuildResult",
|
|
16
|
+
"build_apk",
|
|
17
|
+
"build_manifest",
|
|
18
|
+
"collect_sources",
|
|
19
|
+
"SourceSet",
|
|
20
|
+
"prepare_icons",
|
|
21
|
+
"IconSet",
|
|
22
|
+
"ApkPackager",
|
|
23
|
+
"PackageResult",
|
|
24
|
+
"BuildCache",
|
|
25
|
+
"create_project",
|
|
26
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Build backends.
|
|
2
|
+
|
|
3
|
+
* :mod:`native` — full Android toolchain, produces an installable APK.
|
|
4
|
+
The default structural packager lives in :mod:`pymobile.compiler.packager`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .native import NativeBackend, NativeBuildResult
|
|
10
|
+
|
|
11
|
+
__all__ = ["NativeBackend", "NativeBuildResult"]
|