FazBrowse GitHub Viewer
|
Trending
|
URL:
|
Home
Tools:
[Download Repo ZIP]
[View Raw Code]
[Original HTTPS Page]
pythonnative/src/pythonnative/project/deps.py at main · pythonnative/pythonnative · GitHub
pythonnative
/
pythonnative
Public
Notifications
You must be signed in to change notification settings
Fork
23
Star
131
Code
Issues
11
Pull requests
4
Actions
Projects
Security and quality
0
Insights
Additional navigation options
Code
Issues
Pull requests
Actions
Projects
Security and quality
Insights
Expand file tree
Breadcrumbs
pythonnative
/
src
/
pythonnative
/
project
/
deps.py
Copy path
More file actions
More file actions
Latest commit
History
History
History
719 lines (600 loc) · 27.1 KB
Breadcrumbs
pythonnative
/
src
/
pythonnative
/
project
/
deps.py
Copy path
File metadata and controls
719 lines (600 loc) · 27.1 KB
Raw
Copy raw file
Download raw file
Open symbols panel
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
"""Target-aware resolution of ``[requirements].packages``.
A PythonNative app runs on an embedded CPython, so its third-party
packages have to be resolved for the *device*, not for the machine
running ``pn``. Pure-Python wheels (``py3-none-any``) work everywhere,
but anything with a compiled extension needs a wheel built for the
exact platform triple the app will run on:
- iOS (PEP 730): ``ios_13_0_arm64_iphoneos`` for devices and
``ios_13_0_arm64_iphonesimulator`` / ``ios_13_0_x86_64_iphonesimulator``
for the Simulator, published on PyPI and on BeeWare's index.
- Android (PEP 738): ``android_24_arm64_v8a`` / ``android_24_x86_64``,
published on PyPI and on Chaquopy's index.
This module models those targets ([`Target`][pythonnative.project.deps.Target]),
asks pip to resolve the project's requirements against each of them
without installing anything ([`resolve`][pythonnative.project.deps.resolve]),
and installs the resolved set into a per-target directory for the
iOS build ([`install`][pythonnative.project.deps.install]). ``pip``
does the actual work through its ``--platform`` / ``--python-version``
/ ``--only-binary=:all:`` cross-resolution mode, so the host
interpreter's version and platform never leak into the app.
On Android the authoritative install is done by Chaquopy inside the
Gradle build (with the same ``--only-binary`` rule); resolving here is
a fast preview that catches missing wheels before a multi-minute build.
All pip invocations go through the builder's
[`CommandRunner`][pythonnative.project.builder.CommandRunner] so the
logic is unit testable with a recording fake.
"""
from
__future__
import
annotations
import
json
import
re
import
sys
from
dataclasses
import
dataclass
,
field
from
pathlib
import
Path
from
typing
import
TYPE_CHECKING
,
Any
,
Dict
,
Iterable
,
List
,
Optional
,
Sequence
,
Tuple
from
urllib
.
parse
import
urlparse
from
.
config
import
AppConfig
if
TYPE_CHECKING
:
from
.
builder
import
CommandRunner
BEEWARE_INDEX_URL
=
"https://pypi.anaconda.org/beeware/simple"
"""BeeWare's wheel index: iOS binary wheels for the CPython versions Python-Apple-support ships."""
CHAQUOPY_INDEX_URL
=
"https://chaquo.com/pypi-13.1/"
"""Chaquopy's wheel index: Android binary wheels built for its embedded CPython."""
MIN_IOS_WHEEL_VERSION
=
(
12
,
0
)
"""Oldest iOS version a PEP 730 wheel can be tagged with (CPython's own floor)."""
MIN_ANDROID_WHEEL_API
=
16
"""Oldest Android API level a PEP 738 wheel can be tagged with."""
IOS_SDKS
=
(
"iphoneos"
,
"iphonesimulator"
)
"""The two iOS SDKs a build can target; each gets its own ``app_packages`` slice."""
ANDROID_ABI_TAGS
:
Dict
[
str
,
str
]
=
{
"arm64-v8a"
:
"arm64_v8a"
,
"x86_64"
:
"x86_64"
}
"""Gradle ABI name -> wheel platform tag component."""
# ======================================================================
# Targets
# ======================================================================
@
dataclass
(
frozen
=
True
)
class
Target
:
"""One device platform to resolve packages for.
Attributes:
platform: ``"ios"`` or ``"android"``.
python_version: CPython ``major.minor`` the app embeds.
arch: CPU architecture (``"arm64"`` or ``"x86_64"``).
sdk: iOS SDK (``"iphoneos"`` / ``"iphonesimulator"``); empty on
Android.
os_version: Minimum OS version the app declares: the iOS
deployment target (``"13.0"``) or the Android API level as a
string (``"24"``).
"""
platform
:
str
python_version
:
str
arch
:
str
sdk
:
str
=
""
os_version
:
str
=
"13.0"
@
property
def
label
(
self
)
->
str
:
"""Short human-readable name, e.g. ``"iOS Simulator (arm64)"``."""
if
self
.
platform
==
"ios"
:
kind
=
"iOS device"
if
self
.
sdk
==
"iphoneos"
else
"iOS Simulator"
return
f"
{
kind
}
(
{
self
.
arch
}
, iOS
{
self
.
os_version
}
+)"
return
f"Android
{
self
.
abi
}
(API
{
self
.
os_version
}
+)"
@
property
def
abi
(
self
)
->
str
:
"""The Gradle ABI name for an Android target (``"arm64-v8a"``)."""
for
abi
,
tag
in
ANDROID_ABI_TAGS
.
items
():
if
tag
==
self
.
arch
or
abi
==
self
.
arch
:
return
abi
return
self
.
arch
@
property
def
slice_name
(
self
)
->
str
:
"""The staged packages folder name for an iOS target (``app_packages.iphoneos``)."""
return
f"app_packages.
{
self
.
sdk
}
"
@
property
def
platform_tags
(
self
)
->
List
[
str
]:
"""Every wheel platform tag this target accepts, newest first.
A wheel is tagged with the *minimum* OS version it supports, so
a target on iOS 13.0 also accepts wheels tagged for 12.x.
Mirrors ``packaging.tags.ios_platforms`` /
``android_platforms`` so the list stays in step with what pip
itself would compute on the device.
"""
if
self
.
platform
==
"ios"
:
return
list
(
_ios_platform_tags
(
self
.
os_version
,
f"
{
self
.
arch
}
_
{
self
.
sdk
}
"
))
return
list
(
_android_platform_tags
(
int
(
self
.
os_version
),
ANDROID_ABI_TAGS
.
get
(
self
.
arch
,
self
.
arch
)))
@
property
def
index_urls
(
self
)
->
List
[
str
]:
"""Extra indexes searched after PyPI: the platform's binary wheel repository."""
return
[
BEEWARE_INDEX_URL
]
if
self
.
platform
==
"ios"
else
[
CHAQUOPY_INDEX_URL
]
@
property
def
abi_tag
(
self
)
->
str
:
"""The CPython ABI tag (``cp313``)."""
return
"cp"
+
self
.
python_version
.
replace
(
"."
,
""
)
def
_ios_platform_tags
(
version
:
str
,
multiarch
:
str
)
->
Iterable
[
str
]:
major
,
minor
=
_parse_version
(
version
)
for
maj
in
range
(
major
,
MIN_IOS_WHEEL_VERSION
[
0
]
-
1
,
-
1
):
top
=
minor
if
maj
==
major
else
9
bottom
=
MIN_IOS_WHEEL_VERSION
[
1
]
if
maj
==
MIN_IOS_WHEEL_VERSION
[
0
]
else
0
for
mnr
in
range
(
top
,
bottom
-
1
,
-
1
):
yield
f"ios_
{
maj
}
_
{
mnr
}
_
{
multiarch
}
"
def
_android_platform_tags
(
api_level
:
int
,
abi
:
str
)
->
Iterable
[
str
]:
for
level
in
range
(
api_level
,
MIN_ANDROID_WHEEL_API
-
1
,
-
1
):
yield
f"android_
{
level
}
_
{
abi
}
"
def
_parse_version
(
version
:
str
)
->
Tuple
[
int
,
int
]:
parts
=
version
.
split
(
"."
)
major
=
int
(
parts
[
0
])
minor
=
int
(
parts
[
1
])
if
len
(
parts
)
>
1
else
0
return
major
,
minor
def
host_simulator_arch
()
->
str
:
"""The iOS Simulator architecture matching this Mac (``arm64`` or ``x86_64``)."""
import
platform
as
platform_module
return
"arm64"
if
platform_module
.
machine
()
==
"arm64"
else
"x86_64"
def
ios_targets
(
config
:
AppConfig
,
*
,
sdks
:
Sequence
[
str
]
=
IOS_SDKS
,
simulator_arch
:
Optional
[
str
]
=
None
)
->
List
[
Target
]:
"""Build the iOS targets for ``config``.
Args:
config: The validated app configuration.
sdks: Which SDKs to include (default both).
simulator_arch: Architecture for the Simulator target; defaults
to the host Mac's, which is also what the builder pins
``ARCHS`` to.
Returns:
One [`Target`][pythonnative.project.deps.Target] per SDK.
"""
targets
:
List
[
Target
]
=
[]
for
sdk
in
sdks
:
if
sdk
not
in
IOS_SDKS
:
raise
ValueError
(
f"Unknown iOS SDK
{
sdk
!r
}
(expected one of
{
IOS_SDKS
}
)."
)
arch
=
"arm64"
if
sdk
==
"iphoneos"
else
(
simulator_arch
or
host_simulator_arch
())
targets
.
append
(
Target
(
platform
=
"ios"
,
python_version
=
config
.
python_version
,
arch
=
arch
,
sdk
=
sdk
,
os_version
=
config
.
ios
.
deployment_target
,
)
)
return
targets
def
android_targets
(
config
:
AppConfig
)
->
List
[
Target
]:
"""Build one Android target per ``[android].abi_filters`` entry."""
return
[
Target
(
platform
=
"android"
,
python_version
=
config
.
python_version
,
arch
=
ANDROID_ABI_TAGS
.
get
(
abi
,
abi
),
os_version
=
str
(
config
.
android
.
min_sdk
),
)
for
abi
in
config
.
android
.
abi_filters
]
def
targets_for
(
config
:
AppConfig
,
platform
:
Optional
[
str
]
=
None
,
*
,
all_simulator_archs
:
bool
=
False
)
->
List
[
Target
]:
"""Targets for a platform, optionally including both Mac architectures for portable locks."""
targets
:
List
[
Target
]
=
[]
if
platform
in
(
None
,
"ios"
):
if
all_simulator_archs
:
targets
.
extend
(
ios_targets
(
config
,
simulator_arch
=
"arm64"
))
targets
.
extend
(
ios_targets
(
config
,
sdks
=
(
"iphonesimulator"
,),
simulator_arch
=
"x86_64"
))
else
:
targets
.
extend
(
ios_targets
(
config
))
if
platform
in
(
None
,
"android"
):
targets
.
extend
(
android_targets
(
config
))
return
targets
# ======================================================================
# pip invocations
# ======================================================================
def
pip_base_args
(
config
:
AppConfig
,
target
:
Target
,
*
,
python
:
Optional
[
str
]
=
None
)
->
List
[
str
]:
"""The pip arguments that select ``target`` for cross-platform resolution.
Args:
config: The app configuration (for ``extra_index_urls``).
target: The device target.
python: Interpreter to run pip with; defaults to the current one.
Returns:
``[python, "-m", "pip", "install", <selection flags...>]`` with
no requirements appended.
"""
args
=
[
python
or
sys
.
executable
,
"-m"
,
"pip"
,
"install"
,
"--disable-pip-version-check"
,
# Cross-resolution: pip refuses to build from source for a foreign
# platform, which is also exactly what an app store build needs.
"--only-binary=:all:"
,
"--implementation"
,
"cp"
,
"--python-version"
,
target
.
python_version
,
"--abi"
,
target
.
abi_tag
,
]
for
tag
in
target
.
platform_tags
:
args
.
extend
([
"--platform"
,
tag
])
for
url
in
[
*
target
.
index_urls
,
*
config
.
extra_index_urls
]:
args
.
extend
([
"--extra-index-url"
,
url
])
return
args
def
report_args
(
config
:
AppConfig
,
target
:
Target
,
*
,
python
:
Optional
[
str
]
=
None
)
->
List
[
str
]:
"""Pip arguments that resolve ``config.requirements`` for ``target`` without installing.
The JSON report (``--report -``) lists every wheel pip *would*
install, which is what [`resolve`][pythonnative.project.deps.resolve]
parses.
"""
return
[
*
pip_base_args
(
config
,
target
,
python
=
python
),
"--dry-run"
,
"--ignore-installed"
,
"--quiet"
,
"--report"
,
"-"
,
*
config
.
requirements
,
]
def
install_args
(
config
:
AppConfig
,
target
:
Target
,
dest
:
Path
,
*
,
python
:
Optional
[
str
]
=
None
)
->
List
[
str
]:
"""Pip arguments that install ``config.requirements`` for ``target`` into ``dest``."""
return
[
*
pip_base_args
(
config
,
target
,
python
=
python
),
"--target"
,
str
(
dest
),
"--upgrade"
,
*
config
.
requirements
,
]
# ======================================================================
# Results
# ======================================================================
@
dataclass
class
ResolvedPackage
:
"""One wheel pip selected for a target.
Attributes:
name: Distribution name as reported by pip.
version: Selected version.
filename: The wheel filename (its tags say what it is built for).
url: Where the wheel comes from.
"""
name
:
str
version
:
str
filename
:
str
url
:
str
=
""
sha256
:
str
=
""
latest
:
str
=
""
"""The version an unconstrained desktop resolution picks, when it is newer than ``version``.
Set by [`mark_downgrades`][pythonnative.project.deps.mark_downgrades].
A non-empty value means pip walked back to an older release because
the newer ones have no wheel for this target.
"""
@
property
def
downgraded
(
self
)
->
bool
:
"""Whether this target got an older release than the desktop would."""
return
bool
(
self
.
latest
)
@
property
def
is_pure
(
self
)
->
bool
:
"""Whether this is a platform-independent (``none-any``) wheel."""
return
self
.
filename
.
endswith
(
"-none-any.whl"
)
@
property
def
platform_tag
(
self
)
->
str
:
"""The wheel's platform tag (``"any"`` for pure-Python wheels)."""
stem
=
self
.
filename
[:
-
4
]
if
self
.
filename
.
endswith
(
".whl"
)
else
self
.
filename
return
stem
.
rsplit
(
"-"
,
1
)[
-
1
]
@
property
def
index_host
(
self
)
->
str
:
"""Host name of the index the wheel is served from (``"files.pythonhosted.org"`` for PyPI)."""
return
urlparse
(
self
.
url
).
netloc
if
self
.
url
else
""
@
property
def
source_label
(
self
)
->
str
:
"""Friendly source name: ``PyPI``, ``BeeWare``, ``Chaquopy``, or the index host."""
host
=
self
.
index_host
if
host
.
endswith
(
"pythonhosted.org"
)
or
host
.
endswith
(
"pypi.org"
):
return
"PyPI"
if
host
==
"pypi.anaconda.org"
and
"/beeware/"
in
self
.
url
:
return
"BeeWare"
if
host
==
"chaquo.com"
:
return
"Chaquopy"
return
host
or
"local"
@
dataclass
class
Resolution
:
"""The outcome of resolving the requirements for one target.
Attributes:
target: The target that was resolved.
packages: Wheels pip selected (empty when ``error`` is set or
there are no requirements).
error: pip's failure explanation when resolution failed, else
``None``.
missing: Requirement names pip could not satisfy, parsed from
``error`` (best effort).
"""
target
:
Target
packages
:
List
[
ResolvedPackage
]
=
field
(
default_factory
=
list
)
error
:
Optional
[
str
]
=
None
missing
:
List
[
str
]
=
field
(
default_factory
=
list
)
@
property
def
ok
(
self
)
->
bool
:
"""Whether every requirement resolved to a wheel."""
return
self
.
error
is
None
@
property
def
binary_packages
(
self
)
->
List
[
ResolvedPackage
]:
"""The platform-specific wheels in ``packages``."""
return
[
pkg
for
pkg
in
self
.
packages
if
not
pkg
.
is_pure
]
@
property
def
downgraded_packages
(
self
)
->
List
[
ResolvedPackage
]:
"""Packages that resolved to an older release than the desktop would get."""
return
[
pkg
for
pkg
in
self
.
packages
if
pkg
.
downgraded
]
def
to_dict
(
self
)
->
Dict
[
str
,
Any
]:
"""Serialize for ``pn deps --json`` and the compatibility matrix job."""
return
{
"platform"
:
self
.
target
.
platform
,
"label"
:
self
.
target
.
label
,
"python_version"
:
self
.
target
.
python_version
,
"arch"
:
self
.
target
.
arch
,
"sdk"
:
self
.
target
.
sdk
,
"ok"
:
self
.
ok
,
"error"
:
self
.
error
,
"missing"
:
list
(
self
.
missing
),
"packages"
: [
{
"name"
:
pkg
.
name
,
"version"
:
pkg
.
version
,
"filename"
:
pkg
.
filename
,
"pure"
:
pkg
.
is_pure
,
"platform_tag"
:
pkg
.
platform_tag
,
"source"
:
pkg
.
source_label
,
"url"
:
pkg
.
url
,
"latest"
:
pkg
.
latest
or
None
,
}
for
pkg
in
self
.
packages
],
}
class
DependencyError
(
Exception
):
"""Raised when requirements can't be resolved or installed for a target.
The message is user-facing: it names the target, the unsatisfiable
requirement(s), and what to do about it.
"""
# ======================================================================
# Resolving
# ======================================================================
_NO_MATCH_RE
=
re
.
compile
(
r"No matching distribution found for (?P<req>[^\s]+)"
)
_NO_VERSION_RE
=
re
.
compile
(
r"Could not find a version that satisfies the requirement (?P<req>[^\s]+)"
)
def
parse_report
(
report_json
:
str
,
target
:
Target
)
->
Resolution
:
"""Turn a pip ``--report`` JSON document into a [`Resolution`][pythonnative.project.deps.Resolution]."""
data
=
json
.
loads
(
report_json
or
"{}"
)
packages
:
List
[
ResolvedPackage
]
=
[]
for
item
in
data
.
get
(
"install"
, []):
metadata
=
item
.
get
(
"metadata"
, {})
download
=
item
.
get
(
"download_info"
, {})
or
{}
url
=
str
(
download
.
get
(
"url"
,
""
))
filename
=
url
.
rsplit
(
"/"
,
1
)[
-
1
]
if
url
else
""
packages
.
append
(
ResolvedPackage
(
name
=
str
(
metadata
.
get
(
"name"
,
"?"
)),
version
=
str
(
metadata
.
get
(
"version"
,
"?"
)),
filename
=
filename
,
url
=
url
,
sha256
=
str
(
download
.
get
(
"archive_info"
, {}).
get
(
"hashes"
, {}).
get
(
"sha256"
,
""
)),
)
)
packages
.
sort
(
key
=
lambda
pkg
:
pkg
.
name
.
lower
())
return
Resolution
(
target
=
target
,
packages
=
packages
)
def
parse_failure
(
stderr
:
str
)
->
Tuple
[
str
,
List
[
str
]]:
"""Extract a one-paragraph explanation and the unsatisfied requirement names from pip's stderr."""
text
=
(
stderr
or
""
).
strip
()
missing
:
List
[
str
]
=
[]
for
match
in
_NO_MATCH_RE
.
finditer
(
text
):
req
=
match
.
group
(
"req"
).
strip
(
".,"
)
if
req
not
in
missing
:
missing
.
append
(
req
)
if
not
missing
:
for
match
in
_NO_VERSION_RE
.
finditer
(
text
):
req
=
match
.
group
(
"req"
).
strip
(
".,"
)
if
req
not
in
missing
:
missing
.
append
(
req
)
# pip prints the useful sentence(s) as ERROR: lines; everything else is noise.
lines
=
[
line
for
line
in
text
.
splitlines
()
if
line
.
startswith
(
"ERROR:"
)]
summary
=
"
\n
"
.
join
(
lines
)
if
lines
else
(
text
.
splitlines
()[
-
1
]
if
text
else
"pip failed without output"
)
return
summary
,
missing
def
resolve
(
config
:
AppConfig
,
target
:
Target
,
*
,
runner
:
CommandRunner
,
python
:
Optional
[
str
]
=
None
)
->
Resolution
:
"""Resolve ``config.requirements`` for ``target`` without installing.
Args:
config: The validated app configuration.
target: The device target.
runner: A [`CommandRunner`][pythonnative.project.builder.CommandRunner].
python: Interpreter to run pip with (defaults to the current one).
Returns:
A [`Resolution`][pythonnative.project.deps.Resolution]; check
``.ok`` rather than expecting an exception, so callers can
report every target at once.
"""
if
not
config
.
requirements
:
return
Resolution
(
target
=
target
)
result
=
runner
.
run
(
report_args
(
config
,
target
,
python
=
python
),
capture
=
True
)
if
not
result
.
ok
:
summary
,
missing
=
parse_failure
(
result
.
stderr
)
return
Resolution
(
target
=
target
,
error
=
summary
,
missing
=
missing
)
try
:
return
parse_report
(
result
.
stdout
,
target
)
except
(
ValueError
,
TypeError
)
as
exc
:
return
Resolution
(
target
=
target
,
error
=
f"Could not parse pip's report:
{
exc
}
"
)
def
resolve_all
(
config
:
AppConfig
,
targets
:
Sequence
[
Target
],
*
,
runner
:
CommandRunner
,
python
:
Optional
[
str
]
=
None
,
detect_downgrades
:
bool
=
True
,
)
->
List
[
Resolution
]:
"""[`resolve`][pythonnative.project.deps.resolve] every target in order.
Args:
config: The validated app configuration.
targets: Targets to resolve for.
runner: A [`CommandRunner`][pythonnative.project.builder.CommandRunner].
python: Interpreter to run pip with (defaults to the current one).
detect_downgrades: Also resolve once without any platform
constraint and mark packages that got an older release on a
target than that unconstrained resolution picked (see
[`mark_downgrades`][pythonnative.project.deps.mark_downgrades]).
One extra pip call; skipped when there are no requirements.
"""
resolutions
=
[
resolve
(
config
,
target
,
runner
=
runner
,
python
=
python
)
for
target
in
targets
]
if
detect_downgrades
and
config
.
requirements
and
any
(
res
.
ok
and
res
.
packages
for
res
in
resolutions
):
reference
=
resolve_reference
(
config
,
runner
=
runner
,
python
=
python
)
if
reference
is
not
None
:
mark_downgrades
(
resolutions
,
reference
)
return
resolutions
def
reference_args
(
config
:
AppConfig
,
*
,
python
:
Optional
[
str
]
=
None
)
->
List
[
str
]:
"""Pip arguments for an unconstrained dry-run resolution of ``config.requirements``.
No platform or ABI flags and source distributions allowed, so pip
picks the newest release it would give a desktop. Only the versions
matter; nothing is downloaded beyond metadata.
"""
return
[
python
or
sys
.
executable
,
"-m"
,
"pip"
,
"install"
,
"--disable-pip-version-check"
,
"--dry-run"
,
"--ignore-installed"
,
"--quiet"
,
"--report"
,
"-"
,
*
[
arg
for
url
in
config
.
extra_index_urls
for
arg
in
(
"--extra-index-url"
,
url
)],
*
config
.
requirements
,
]
def
resolve_reference
(
config
:
AppConfig
,
*
,
runner
:
CommandRunner
,
python
:
Optional
[
str
]
=
None
)
->
Optional
[
Dict
[
str
,
str
]]:
"""Resolve the requirements with no target constraint; return ``{name: version}``.
Returns ``None`` when pip can't resolve them at all (the per-target
errors already say why) or the report can't be parsed.
"""
if
not
config
.
requirements
:
return
{}
result
=
runner
.
run
(
reference_args
(
config
,
python
=
python
),
capture
=
True
)
if
not
result
.
ok
:
return
None
try
:
data
=
json
.
loads
(
result
.
stdout
or
"{}"
)
except
ValueError
:
return
None
versions
:
Dict
[
str
,
str
]
=
{}
for
item
in
data
.
get
(
"install"
, []):
metadata
=
item
.
get
(
"metadata"
, {})
versions
[
_canonical
(
str
(
metadata
.
get
(
"name"
,
""
)))]
=
str
(
metadata
.
get
(
"version"
,
""
))
return
versions
def
mark_downgrades
(
resolutions
:
Sequence
[
Resolution
],
reference
:
Dict
[
str
,
str
])
->
None
:
"""Set ``latest`` on every resolved package that is older than in ``reference``.
pip's cross-resolution silently walks back through a package's
release history until it finds a version with a usable wheel for
the target. That is the right call for a build (it works), but it
is a surprise for the developer who gets a two-year-old API on the
device and the current one in their tests. Marking the gap lets
``pn deps`` say so.
Args:
resolutions: Per-target resolutions to annotate in place.
reference: ``{canonical name: version}`` from an unconstrained
resolution (see
[`resolve_reference`][pythonnative.project.deps.resolve_reference]).
"""
for
res
in
resolutions
:
for
pkg
in
res
.
packages
:
latest
=
reference
.
get
(
_canonical
(
pkg
.
name
))
if
latest
and
_version_key
(
latest
)
>
_version_key
(
pkg
.
version
):
pkg
.
latest
=
latest
def
_canonical
(
name
:
str
)
->
str
:
return
re
.
sub
(
r"[-_.]+"
,
"-"
,
name
).
lower
()
def
_version_key
(
version
:
str
)
->
Tuple
[
int
, ...]:
"""Order versions by their numeric release segment, enough to detect a walk-back.
Pre/post/dev suffixes are ignored on purpose: a ``2.5.2`` versus
``2.5.2.post1`` gap is not the kind of downgrade worth a warning.
"""
match
=
re
.
match
(
r"^\d+(?:\.\d+)*"
,
version
)
return
tuple
(
int
(
part
)
for
part
in
match
.
group
(
0
).
split
(
"."
))
if
match
else
()
def
install
(
config
:
AppConfig
,
target
:
Target
,
dest
:
Path
,
*
,
runner
:
CommandRunner
,
python
:
Optional
[
str
]
=
None
,
)
->
None
:
"""Install ``config.requirements`` for ``target`` into ``dest``.
Args:
config: The validated app configuration.
target: The device target (an iOS one; Android installs happen in
Gradle).
dest: The ``app_packages.<sdk>`` directory to populate.
runner: A [`CommandRunner`][pythonnative.project.builder.CommandRunner].
python: Interpreter to run pip with (defaults to the current one).
Raises:
DependencyError: When pip can't satisfy the requirements for the
target, with the unsatisfiable requirement(s) named.
"""
if
not
config
.
requirements
:
return
dest
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
from
.
lockfile
import
requirements
locked
=
requirements
(
config
, [
target
],
direct
=
True
)
command
=
install_args
(
config
,
target
,
dest
,
python
=
python
)
if
locked
is
not
None
:
locked_path
=
dest
.
parent
/
f"requirements.
{
target
.
slice_name
}
.lock"
locked_path
.
write_text
(
locked
,
encoding
=
"utf-8"
)
command
=
command
[:
-
len
(
config
.
requirements
)]
+
[
"--no-deps"
,
"-r"
,
str
(
locked_path
)]
result
=
runner
.
run
(
command
,
capture
=
True
)
if
result
.
ok
:
return
summary
,
missing
=
parse_failure
(
result
.
stderr
)
raise
DependencyError
(
explain_failure
(
target
,
summary
,
missing
))
def
explain_failure
(
target
:
Target
,
summary
:
str
,
missing
:
Sequence
[
str
])
->
str
:
"""Compose the user-facing message for a failed resolution."""
names
=
", "
.
join
(
missing
)
if
missing
else
"one or more requirements"
lines
=
[
f"Could not resolve
{
names
}
for
{
target
.
label
}
(Python
{
target
.
python_version
}
)."
]
if
summary
:
lines
.
append
(
summary
)
if
target
.
platform
==
"ios"
:
lines
.
append
(
"iOS needs a wheel tagged for the device (ios_*_arm64_iphoneos) and, for 'pn run ios', the "
"Simulator (ios_*_iphonesimulator). Pure-Python packages always work; for a C extension, check "
f"
{
BEEWARE_INDEX_URL
}
or ask upstream for iOS wheels (PEP 730)."
)
else
:
lines
.
append
(
"Android needs a wheel tagged android_*_arm64_v8a / android_*_x86_64 (PEP 738). Pure-Python "
f"packages always work; for a C extension, check
{
CHAQUOPY_INDEX_URL
}
or PyPI's Android wheels."
)
lines
.
append
(
"Run 'pn deps' for a per-target report; see https://pythonnative.com/guides/pypi-packages/."
)
return
"
\n
"
.
join
(
lines
)
# ======================================================================
# Reporting
# ======================================================================
def
format_report
(
resolutions
:
Sequence
[
Resolution
],
*
,
requirements
:
Sequence
[
str
])
->
str
:
"""Render resolutions as the terminal report ``pn deps`` prints."""
if
not
requirements
:
return
"No [requirements].packages declared in pythonnative.toml; nothing to resolve."
out
:
List
[
str
]
=
[]
for
res
in
resolutions
:
header
=
res
.
target
.
label
if
res
.
target
.
platform
==
"android"
:
header
+=
" (preview; Chaquopy resolves again inside the Gradle build)"
out
.
append
(
header
)
if
not
res
.
ok
:
out
.
append
(
" [x] "
+
(
res
.
error
or
"resolution failed"
).
replace
(
"
\n
"
,
"
\n
"
))
out
.
append
(
""
)
continue
width
=
max
((
len
(
f"
{
pkg
.
name
}
{
pkg
.
version
}
"
)
for
pkg
in
res
.
packages
),
default
=
0
)
for
pkg
in
res
.
packages
:
name
=
f"
{
pkg
.
name
}
{
pkg
.
version
}
"
.
ljust
(
width
)
kind
=
"pure Python"
if
pkg
.
is_pure
else
f"binary wheel
{
pkg
.
platform_tag
}
(
{
pkg
.
source_label
}
)"
marker
=
"[!!]"
if
pkg
.
downgraded
else
"[ok]"
out
.
append
(
f"
{
marker
}
{
name
}
{
kind
}
"
)
if
pkg
.
downgraded
:
out
.
append
(
f" older than the desktop resolution (
{
pkg
.
latest
}
): newer releases have no wheel here"
)
out
.
append
(
""
)
failed
=
[
res
for
res
in
resolutions
if
not
res
.
ok
]
downgraded
=
sorted
({
pkg
.
name
for
res
in
resolutions
for
pkg
in
res
.
downgraded_packages
},
key
=
str
.
lower
)
if
downgraded
:
out
.
append
(
f"Older release selected for:
{
', '
.
join
(
downgraded
)
}
([!!] above). Pin a version in "
"[requirements].packages if the API difference matters."
)
if
failed
:
out
.
append
(
f"
{
len
(
failed
)
}
of
{
len
(
resolutions
)
}
targets cannot be satisfied. See the [x] lines above."
)
else
:
out
.
append
(
f"All
{
len
(
resolutions
)
}
targets resolved."
)
return
"
\n
"
.
join
(
out
)
Back
|
FazBrowse Home
|
New Git URL