py2apk-compiler 0.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.
Files changed (50) hide show
  1. py2apk_compiler-0.1.0/PKG-INFO +144 -0
  2. py2apk_compiler-0.1.0/Py2APK/__init__.py +0 -0
  3. py2apk_compiler-0.1.0/Py2APK/classfile.py +191 -0
  4. py2apk_compiler-0.1.0/Py2APK/cli.py +159 -0
  5. py2apk_compiler-0.1.0/Py2APK/cli_build.py +273 -0
  6. py2apk_compiler-0.1.0/Py2APK/cli_compile.py +93 -0
  7. py2apk_compiler-0.1.0/Py2APK/cli_fetch_jars.py +158 -0
  8. py2apk_compiler-0.1.0/Py2APK/cli_fetch_platform.py +115 -0
  9. py2apk_compiler-0.1.0/Py2APK/cli_init.py +137 -0
  10. py2apk_compiler-0.1.0/Py2APK/cli_setup.py +208 -0
  11. py2apk_compiler-0.1.0/Py2APK/compiler_j.py +895 -0
  12. py2apk_compiler-0.1.0/Py2APK/downloader.py +95 -0
  13. py2apk_compiler-0.1.0/Py2APK/jar_index.py +361 -0
  14. py2apk_compiler-0.1.0/Py2APK/maven_resolve.py +316 -0
  15. py2apk_compiler-0.1.0/README.md +134 -0
  16. py2apk_compiler-0.1.0/py2apk_compiler.egg-info/PKG-INFO +144 -0
  17. py2apk_compiler-0.1.0/py2apk_compiler.egg-info/SOURCES.txt +48 -0
  18. py2apk_compiler-0.1.0/py2apk_compiler.egg-info/dependency_links.txt +1 -0
  19. py2apk_compiler-0.1.0/py2apk_compiler.egg-info/entry_points.txt +2 -0
  20. py2apk_compiler-0.1.0/py2apk_compiler.egg-info/requires.txt +2 -0
  21. py2apk_compiler-0.1.0/py2apk_compiler.egg-info/top_level.txt +1 -0
  22. py2apk_compiler-0.1.0/pyproject.toml +23 -0
  23. py2apk_compiler-0.1.0/setup.cfg +4 -0
  24. py2apk_compiler-0.1.0/setup.py +35 -0
  25. py2apk_compiler-0.1.0/templates/add2/_template.keep +1 -0
  26. py2apk_compiler-0.1.0/templates/blank/_template.keep +1 -0
  27. py2apk_compiler-0.1.0/templates/chart/deps.txt +2 -0
  28. py2apk_compiler-0.1.0/templates/chart/src/__PACKAGE__/MainActivity.py +33 -0
  29. py2apk_compiler-0.1.0/templates/chart/target/AndroidManifest.xml +25 -0
  30. py2apk_compiler-0.1.0/templates/chart/target/apktool.yml +20 -0
  31. py2apk_compiler-0.1.0/templates/chart/target/res/values/strings.xml +4 -0
  32. py2apk_compiler-0.1.0/templates/helloworld/src/com/example/helloworld/DialogClickListener.py +18 -0
  33. py2apk_compiler-0.1.0/templates/helloworld/src/com/example/helloworld/GreetingTextWatcher.py +37 -0
  34. py2apk_compiler-0.1.0/templates/helloworld/src/com/example/helloworld/MainActivity.py +54 -0
  35. py2apk_compiler-0.1.0/templates/helloworld/target/AndroidManifest.xml +25 -0
  36. py2apk_compiler-0.1.0/templates/helloworld/target/apktool.yml +20 -0
  37. py2apk_compiler-0.1.0/templates/helloworld/target/res/values/strings.xml +4 -0
  38. py2apk_compiler-0.1.0/templates/weather/deps.txt +2 -0
  39. py2apk_compiler-0.1.0/templates/weather/src/__PACKAGE__/MainActivity.py +73 -0
  40. py2apk_compiler-0.1.0/templates/weather/src/__PACKAGE__/RefreshClickListener.py +18 -0
  41. py2apk_compiler-0.1.0/templates/weather/src/__PACKAGE__/WeatherCallback.py +27 -0
  42. py2apk_compiler-0.1.0/templates/weather/src/__PACKAGE__/WeatherLocationListener.py +18 -0
  43. py2apk_compiler-0.1.0/templates/weather/target/AndroidManifest.xml +28 -0
  44. py2apk_compiler-0.1.0/templates/weather/target/apktool.yml +20 -0
  45. py2apk_compiler-0.1.0/templates/weather/target/res/values/strings.xml +4 -0
  46. py2apk_compiler-0.1.0/tests/test_cli.py +164 -0
  47. py2apk_compiler-0.1.0/tests/test_cli_build.py +181 -0
  48. py2apk_compiler-0.1.0/tests/test_cli_compile.py +145 -0
  49. py2apk_compiler-0.1.0/tests/test_cli_setup.py +117 -0
  50. py2apk_compiler-0.1.0/tests/test_jars.py +548 -0
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: py2apk-compiler
3
+ Version: 0.1.0
4
+ Summary: A CLI tool for building Android APKs from Python source trees
5
+ Project-URL: Homepage, https://github.com/muayyad-alsadi/py2apk
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: PyYAML>=6.0
9
+ Requires-Dist: tqdm>=4.66
10
+
11
+ # Py2APK - A cli tool to create Android Apps APK from python source tree
12
+
13
+ I hate heavy bloated IDEs, I hate Android Studio even more.
14
+
15
+ This is a cli tool that let you write your app in python-like syntax
16
+ Then build your apk directly from that code.
17
+
18
+ ```sh
19
+ pip install py2apk-compiler
20
+ py2apk setup
21
+ py2apk init --template weather --pkg com.example.myweather --name "My First Weather App" myweather
22
+ cd myweather
23
+ py2apk build
24
+ ```
25
+
26
+ * `py2apk setup` - set up the stable toolchain JAR files.
27
+ * `py2apk init` - create a new project by copying a packaged template.
28
+ `--icon` is optional. `__PACKAGE__` and `__NAME__` in a template are replaced
29
+ with `--pkg` and `--name`.
30
+ * `py2apk build` - from the project directory, fetch jars when `jars.db` is
31
+ missing, compile changed sources, assemble the APK, and sign it.
32
+
33
+ `fetch_jars`, `fetch_platform`, and `compile` are the same stages `build`
34
+ runs. Use them when you want that stage by itself.
35
+
36
+ * `py2apk fetch_jars` - read `-r` (default `deps.txt`) and download those Maven
37
+ jars plus their runtime transitives. Google Maven and Central are always
38
+ searched. A `deps.txt` line `--extra-index-url URL` adds another repository
39
+ before those two, the same way `requirements.txt` adds an index. `-R URL`
40
+ does that from the command line. The `chart` template uses JitPack that way
41
+ for MPAndroidChart. An `.aar` dependency contributes its `classes.jar`. The
42
+ command also downloads the SDK platform stub for `targetSdkVersion`.
43
+ * `py2apk fetch_platform` - download only that platform stub.
44
+ * `py2apk compile` - find missing or stale Jasmin outputs for Python-like sources.
45
+
46
+ `fetch_jars` caches Maven files under `~/.cache/py2apk/maven/` and the platform
47
+ jar under `~/.cache/py2apk/platforms/<api>/`. Each cached jar gets a sibling
48
+ SQLite file of its public and protected members, in the same directory as the
49
+ Maven `.pom` when the artifact has one. That scan is reused until the jar
50
+ changes. The project `jars/` directory receives dependency symlinks only.
51
+ The platform stub stays in the cache. One merged `jars.db` sits at the project
52
+ root. `jars.kind` is
53
+ `direct`, `transitive`, or `platform`. Compile reads every kind. D8 dexes
54
+ `direct` and `transitive` jars. The platform stub is passed to D8 with
55
+ `--lib` so type lookup succeeds, and it is not written into the dex. The
56
+ toolchain jars from `setup` are not dexed. Downloads use `aiohttp` with five concurrent
57
+ transfers when that package is already installed, and `urllib.request`
58
+ otherwise. `aiohttp` is not a required dependency.
59
+
60
+ `py2apk setup` caches Jasmin and R8 through the Maven cache at
61
+ `~/.cache/py2apk/maven/`. Apktool 3.0.3 and uber-apk-signer 1.3.0 are not
62
+ published to Maven Central, so those two are cached from their GitHub releases
63
+ under `~/.cache/py2apk/toolchain/`. The command symlinks all four into the
64
+ toolchain `jars/` directory. It also creates
65
+ `~/.local/share/py2apk/default.keystore` (alias `py2apk`, RSA 2048, PKCS12,
66
+ 10000 days) and writes the password to `default.keystore.pass` beside it.
67
+ `XDG_DATA_HOME` overrides the directory. An existing keystore is left in place.
68
+
69
+ The command interface is available after installation as `py2apk`, or directly
70
+ from the repository root as `python3 -m Py2APK.cli`. `setup` retrieves
71
+ apktool, Jasmin, R8/D8, and uber-apk-signer. Without `--dst`, it finds a writable
72
+ Py2APK asset directory containing `templates/` and creates the sibling `jars/`
73
+ directory. Pass `-d JARS_DIR` or `--dst JARS_DIR` to choose it explicitly.
74
+ `compile` performs incremental source discovery and emits Jasmin for the
75
+ currently supported typed Python-like subset. `build [DIRECTORY]` does the
76
+ full project build: `fetch_jars` when `jars.db` is missing, then compile,
77
+ Jasmin, D8, and apktool, then signs that APK with
78
+ `~/.local/share/py2apk/default.keystore`. The installable file is
79
+ `target/dist/<name>.signed.apk`, for example `weather.signed.apk`. `init`
80
+ requires a destination that does not exist and recursively copies the
81
+ selected template into it. Templates are package data, so
82
+ this also works after `pip install --user py2apk-compiler`. For templates with
83
+ apktool build inputs, `init` also copies `target/AndroidManifest.xml` and
84
+ `target/apktool.yml` to the project root while preserving their modes and
85
+ modification times. Root files and `src/` are authored inputs; `target/` is
86
+ reproducible build output and may be deleted.
87
+
88
+ ## How does it work
89
+
90
+ `Py2APK/cli.py` owns argument parsing and dispatch. Each non-trivial command is
91
+ implemented in `Py2APK/cli_<COMMAND>.py`, such as `cli_setup.py`,
92
+ `cli_init.py`, `cli_fetch_jars.py`, `cli_compile.py`, and `cli_build.py`.
93
+
94
+ The command `py2apk compile` scans `src/` for missing or stale Jasmin targets and
95
+ uses Python's built-in AST to emit `.j` files under `target/j/`. Method and
96
+ field signatures come from the project's `jars.db`. The compiler supports the
97
+ small, fully typed language subset used by the bundled templates.
98
+
99
+ `py2apk build [DIRECTORY]` fetches jars when `jars.db` is missing, runs that
100
+ compiler, synchronizes newer authored `AndroidManifest.xml` and `apktool.yml`
101
+ files into `target/`, assembles the Jasmin into JVM classes, converts those
102
+ classes to `classes.dex` with D8, asks apktool for the unsigned APK, and signs
103
+ it with uber-apk-signer. The file to install is `target/dist/<name>.signed.apk`.
104
+ It selects the first existing JAR
105
+ directory from `$PY2APK_JARS`, the project's `jars/`, and the `jars/` sibling of
106
+ source or installed `templates/` assets. A selected directory is expected to
107
+ contain the complete toolchain, including `uber-apk-signer-*.jar`. If it does
108
+ not, the error prints the exact `py2apk setup -d JARS_DIR` recovery command. Set
109
+ `PY2APK_JARS=/path/to/jars` when the JARs live elsewhere.
110
+
111
+ See the [internal documentation](docs/interals.md) for the generated directory
112
+ layout and individual build stages.
113
+
114
+ ## Signing development APKs
115
+
116
+ Apktool assembles an APK but does not sign it, and Android will not install an
117
+ unsigned APK. `py2apk build` runs [`uber-apk-signer`](https://github.com/patrickfav/uber-apk-signer)
118
+ on that APK with `~/.local/share/py2apk/default.keystore` and writes
119
+ `target/dist/<name>.signed.apk`. Install that file:
120
+
121
+ ```sh
122
+ adb install -r target/dist/weather.signed.apk
123
+ ```
124
+
125
+ The same signer invocation, if you need to run it by hand, is:
126
+
127
+ ```sh
128
+ java -jar /path/to/uber-apk-signer-1.3.0.jar \
129
+ -a target/dist/helloworld.apk \
130
+ --ks ~/.local/share/py2apk/default.keystore \
131
+ --ksAlias py2apk \
132
+ --ksPass "$(cat ~/.local/share/py2apk/default.keystore.pass)" \
133
+ --ksKeyPass "$(cat ~/.local/share/py2apk/default.keystore.pass)"
134
+ ```
135
+
136
+ This keystore is a local release-style key, not a store you should publish
137
+ with. Do not commit the keystore or its password file.
138
+
139
+ Android Studio is not required for either building or signing Py2APK projects.
140
+ If a release process uses Google's official tools, install only the Android SDK
141
+ command-line tools and Build Tools. `zipalign` is a platform-specific native
142
+ executable, not a JAR; `apksigner` is Java-based but is normally distributed as
143
+ part of the SDK Build Tools. For lightweight development without an SDK install,
144
+ `uber-apk-signer` bundles the alignment and signing workflow behind one command.
File without changes
@@ -0,0 +1,191 @@
1
+ """Parse JVM class files into the members the Jasmin compiler needs.
2
+
3
+ The reader uses only ``struct``. It walks the constant pool and member tables
4
+ and skips attributes by length, so method bodies are never decoded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ from dataclasses import dataclass
11
+
12
+
13
+ ACC_PUBLIC = 0x0001
14
+ ACC_PRIVATE = 0x0002
15
+ ACC_PROTECTED = 0x0004
16
+ ACC_STATIC = 0x0008
17
+ ACC_INTERFACE = 0x0200
18
+ ACC_SYNTHETIC = 0x1000
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ClassMember:
23
+ owner: str
24
+ name: str
25
+ descriptor: str
26
+ is_static: bool
27
+ opcode: str = ""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ParsedClass:
32
+ internal: str
33
+ super_internal: str | None
34
+ is_interface: bool
35
+ interfaces: tuple[str, ...]
36
+ methods: tuple[ClassMember, ...]
37
+ fields: tuple[ClassMember, ...]
38
+
39
+
40
+ class ClassFileError(ValueError):
41
+ """Raised when a class file is not readable."""
42
+
43
+
44
+ class _Reader:
45
+ def __init__(self, data: bytes) -> None:
46
+ self.data = data
47
+ self.offset = 0
48
+
49
+ def u1(self) -> int:
50
+ value = self.data[self.offset]
51
+ self.offset += 1
52
+ return value
53
+
54
+ def u2(self) -> int:
55
+ value = struct.unpack_from(">H", self.data, self.offset)[0]
56
+ self.offset += 2
57
+ return value
58
+
59
+ def u4(self) -> int:
60
+ value = struct.unpack_from(">I", self.data, self.offset)[0]
61
+ self.offset += 4
62
+ return value
63
+
64
+ def skip(self, count: int) -> None:
65
+ self.offset += count
66
+
67
+ def bytes(self, count: int) -> bytes:
68
+ chunk = self.data[self.offset : self.offset + count]
69
+ self.offset += count
70
+ return chunk
71
+
72
+
73
+ def parameter_descriptors(params: str) -> tuple[str, ...]:
74
+ index = 0
75
+ found: list[str] = []
76
+ while index < len(params):
77
+ start = index
78
+ index = _skip_field(params, index)
79
+ found.append(params[start:index])
80
+ return tuple(found)
81
+
82
+
83
+ def split_method_descriptor(descriptor: str) -> tuple[str, str]:
84
+ """Return the concatenated parameter descriptors and the result descriptor."""
85
+ if not descriptor.startswith("(") or ")" not in descriptor:
86
+ raise ClassFileError(f"bad method descriptor: {descriptor}")
87
+ index = 1
88
+ while descriptor[index] != ")":
89
+ index = _skip_field(descriptor, index)
90
+ return descriptor[1:index], descriptor[index + 1 :]
91
+
92
+
93
+ def parse_class(data: bytes) -> ParsedClass:
94
+ reader = _Reader(data)
95
+ if len(data) < 8 or reader.u4() != 0xCAFEBABE:
96
+ raise ClassFileError("class file does not start with 0xCAFEBABE")
97
+ _ = reader.u2()
98
+ _ = reader.u2()
99
+ pool = _constant_pool(reader)
100
+ access = reader.u2()
101
+ internal = _class_name(pool, reader.u2())
102
+ super_index = reader.u2()
103
+ super_internal = _class_name(pool, super_index) if super_index else None
104
+ interfaces = tuple(_class_name(pool, reader.u2()) for _ in range(reader.u2()))
105
+ is_interface = bool(access & ACC_INTERFACE)
106
+ fields = tuple(_members(reader, pool, internal, is_interface, methods=False))
107
+ methods = tuple(_members(reader, pool, internal, is_interface, methods=True))
108
+ return ParsedClass(internal, super_internal, is_interface, interfaces, methods, fields)
109
+
110
+
111
+ def _constant_pool(reader: _Reader) -> list[object]:
112
+ count = reader.u2()
113
+ pool: list[object] = [None] * count
114
+ index = 1
115
+ while index < count:
116
+ tag = reader.u1()
117
+ if tag == 1:
118
+ pool[index] = reader.bytes(reader.u2()).decode("utf-8", "surrogateescape")
119
+ elif tag in {7, 8, 16, 19, 20}:
120
+ pool[index] = ("class", reader.u2()) if tag == 7 else reader.u2()
121
+ elif tag in {3, 4, 9, 10, 11, 12, 17, 18}:
122
+ reader.skip(4)
123
+ elif tag in {5, 6}:
124
+ reader.skip(8)
125
+ index += 1
126
+ elif tag == 15:
127
+ reader.skip(3)
128
+ else:
129
+ raise ClassFileError(f"unknown constant-pool tag {tag}")
130
+ index += 1
131
+ return pool
132
+
133
+
134
+ def _class_name(pool: list[object], index: int) -> str:
135
+ entry = pool[index]
136
+ if not isinstance(entry, tuple) or entry[0] != "class":
137
+ raise ClassFileError(f"constant pool {index} is not a class")
138
+ name = pool[entry[1]]
139
+ if not isinstance(name, str):
140
+ raise ClassFileError(f"class name {entry[1]} is not Utf8")
141
+ return name
142
+
143
+
144
+ def _utf8(pool: list[object], index: int) -> str:
145
+ value = pool[index]
146
+ if not isinstance(value, str):
147
+ raise ClassFileError(f"constant pool {index} is not Utf8")
148
+ return value
149
+
150
+
151
+ def _members(
152
+ reader: _Reader,
153
+ pool: list[object],
154
+ owner: str,
155
+ is_interface: bool,
156
+ *,
157
+ methods: bool,
158
+ ) -> list[ClassMember]:
159
+ found: list[ClassMember] = []
160
+ for _ in range(reader.u2()):
161
+ access = reader.u2()
162
+ name = _utf8(pool, reader.u2())
163
+ descriptor = _utf8(pool, reader.u2())
164
+ for _attribute in range(reader.u2()):
165
+ _ = reader.u2()
166
+ reader.skip(reader.u4())
167
+ visible = bool(access & (ACC_PUBLIC | ACC_PROTECTED))
168
+ if not visible or access & ACC_SYNTHETIC or access & ACC_PRIVATE:
169
+ continue
170
+ is_static = bool(access & ACC_STATIC)
171
+ opcode = ""
172
+ if methods:
173
+ if is_static:
174
+ opcode = "invokestatic"
175
+ elif name == "<init>":
176
+ opcode = "invokespecial"
177
+ elif is_interface:
178
+ opcode = "invokeinterface"
179
+ else:
180
+ opcode = "invokevirtual"
181
+ found.append(ClassMember(owner, name, descriptor, is_static, opcode))
182
+ return found
183
+
184
+
185
+ def _skip_field(descriptor: str, index: int) -> int:
186
+ while descriptor[index] == "[":
187
+ index += 1
188
+ if descriptor[index] == "L":
189
+ end = descriptor.index(";", index)
190
+ return end + 1
191
+ return index + 1
@@ -0,0 +1,159 @@
1
+ """Command-line interface for Py2APK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from Py2APK.cli_build import build_project
11
+ from Py2APK.cli_compile import compile_project
12
+ from Py2APK.cli_fetch_jars import fetch_jars
13
+ from Py2APK.cli_fetch_platform import main as fetch_platform_main
14
+ from Py2APK.cli_setup import download_jars
15
+ from Py2APK.cli_init import init_project
16
+
17
+
18
+ def _setup(args: argparse.Namespace) -> int:
19
+ destination_arg = cast(str | None, args.destination)
20
+ destination = None if destination_arg is None else Path(destination_arg)
21
+ return download_jars(destination)
22
+
23
+
24
+ def _init(args: argparse.Namespace) -> int:
25
+ icon = None if args.icon is None else str(args.icon)
26
+ return init_project(
27
+ template_name=str(args.template),
28
+ icon=icon,
29
+ package=str(args.package),
30
+ name=str(args.name),
31
+ destination=Path(str(args.directory)),
32
+ )
33
+
34
+
35
+ def _fetch_jars(args: argparse.Namespace) -> int:
36
+ repositories = list(args.repositories or [])
37
+ dependency_file = None if args.dependencies is None else Path(args.dependencies)
38
+ return fetch_jars(Path(args.directory).expanduser(), dependency_file, repositories)
39
+
40
+
41
+ def _fetch_platform(args: argparse.Namespace) -> int:
42
+ return fetch_platform_main(Path(args.directory).expanduser())
43
+
44
+
45
+ def _compile(_args: argparse.Namespace) -> int:
46
+ return compile_project()
47
+
48
+
49
+ def _build(args: argparse.Namespace) -> int:
50
+ return build_project(Path(args.directory).expanduser())
51
+
52
+
53
+ def create_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(
55
+ prog="py2apk",
56
+ description="Build Android APKs from Python source trees.",
57
+ )
58
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
59
+
60
+ setup_parser = subparsers.add_parser(
61
+ "setup",
62
+ help="set up the required toolchain JAR files",
63
+ )
64
+ _ = setup_parser.add_argument(
65
+ "-d",
66
+ "--dst",
67
+ dest="destination",
68
+ help="destination jars directory (default: detected package assets)",
69
+ )
70
+ setup_parser.set_defaults(handler=_setup)
71
+
72
+ init_parser = subparsers.add_parser(
73
+ "init",
74
+ help="initialize a project from a template",
75
+ )
76
+ init_parser.add_argument(
77
+ "--template",
78
+ default="blank",
79
+ help="project template (default: blank)",
80
+ )
81
+ init_parser.add_argument("--icon", help="path to the app icon")
82
+ init_parser.add_argument(
83
+ "--pkg",
84
+ dest="package",
85
+ required=True,
86
+ help="Android package name",
87
+ )
88
+ init_parser.add_argument("--name", required=True, help="display name of the app")
89
+ init_parser.add_argument("directory", help="directory to create")
90
+ init_parser.set_defaults(handler=_init)
91
+
92
+ compile_parser = subparsers.add_parser(
93
+ "compile",
94
+ help="find Python sources that need Jasmin output",
95
+ )
96
+ compile_parser.set_defaults(handler=_compile)
97
+
98
+ build_parser = subparsers.add_parser(
99
+ "build",
100
+ help="build a project into an APK",
101
+ )
102
+ build_parser.add_argument(
103
+ "directory",
104
+ nargs="?",
105
+ default=".",
106
+ help="project directory (default: current directory)",
107
+ )
108
+ build_parser.set_defaults(handler=_build)
109
+
110
+ fetch_jars_parser = subparsers.add_parser(
111
+ "fetch_jars",
112
+ help="fetch dependency jars and the Android platform jar",
113
+ )
114
+ fetch_jars_parser.add_argument(
115
+ "-r",
116
+ dest="dependencies",
117
+ help="dependency file (default: deps.txt)",
118
+ )
119
+ fetch_jars_parser.add_argument(
120
+ "-R",
121
+ dest="repositories",
122
+ action="append",
123
+ help="extra Maven repository URL, tried before Central (repeatable)",
124
+ )
125
+ fetch_jars_parser.add_argument(
126
+ "directory",
127
+ nargs="?",
128
+ default=".",
129
+ help="project directory (default: current directory)",
130
+ )
131
+ fetch_jars_parser.set_defaults(handler=_fetch_jars)
132
+
133
+ fetch_platform_parser = subparsers.add_parser(
134
+ "fetch_platform",
135
+ help="fetch the Android platform stub jar for targetSdkVersion",
136
+ )
137
+ fetch_platform_parser.add_argument(
138
+ "directory",
139
+ nargs="?",
140
+ default=".",
141
+ help="project directory (default: current directory)",
142
+ )
143
+ fetch_platform_parser.set_defaults(handler=_fetch_platform)
144
+
145
+ return parser
146
+
147
+
148
+ def main(argv: Sequence[str] | None = None) -> int:
149
+ parser = create_parser()
150
+ args = parser.parse_args(argv)
151
+ handler = getattr(args, "handler", None)
152
+ if handler is None:
153
+ parser.print_help()
154
+ return 0
155
+ return handler(args)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())