pysfi 0.1.12__py3-none-any.whl → 0.1.13__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.
@@ -9,7 +9,7 @@ import shutil
9
9
  import time
10
10
  import zipfile
11
11
  from contextlib import suppress
12
- from dataclasses import dataclass
12
+ from dataclasses import dataclass, field
13
13
  from functools import cached_property
14
14
  from pathlib import Path
15
15
  from typing import Final
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
25
25
 
26
26
  # Default cache directory
27
27
  _DEFAULT_CACHE_DIR = Path.home() / ".pysfi" / ".cache" / "embed-python"
28
-
28
+ _DEFAULT_PYTHON_VER = "3.8.10"
29
29
 
30
30
  # Architecture mapping
31
31
  ARCH_DICT: Final = {
@@ -44,31 +44,34 @@ USER_AGENT: Final = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.3
44
44
  class EmbedDownloader:
45
45
  """Class for downloading Python embeddable package."""
46
46
 
47
+ parent: EmbedInstaller
47
48
  version: str
48
- arch: str
49
- cache_dir: Path
50
- offline: bool
51
- skip_speed_test: bool
52
- timeout: int = 5
53
49
 
54
50
  @cached_property
55
51
  def cache_file(self) -> Path:
56
52
  """Get the cache file path for the embeddable package."""
57
- return self.cache_dir / f"python-{self.version}-embed-{self.arch}.zip"
53
+ return (
54
+ self.parent.cache_dir
55
+ / f"python-{self.version}-embed-{self.parent.arch}.zip"
56
+ )
58
57
 
59
58
  @cached_property
60
59
  def download_urls(self) -> list[str]:
61
60
  """Get the list of URLs to try for download."""
62
- if self.skip_speed_test:
63
- url = self.get_official_url_template(self.arch).format(version=self.version)
61
+ if self.parent.skip_speed_test:
62
+ url = self.get_official_url_template(self.parent.arch).format(
63
+ version=self.latest_path_version
64
+ )
64
65
  logger.info(f"Skipping speed test, using official URL: {url}")
65
66
  return [url]
66
- return self.find_available_urls(self.version, self.arch, self.timeout)
67
+ return self.find_available_urls(
68
+ self.latest_path_version, self.parent.arch, self.parent.timeout
69
+ )
67
70
 
68
71
  def download(self) -> bool:
69
72
  """Download the Python embeddable package to the cache file."""
70
73
  return self.has_cached_file or self.download_python_package(
71
- self.cache_file, self.download_urls, self.offline
74
+ self.cache_file, self.download_urls, self.parent.offline
72
75
  )
73
76
 
74
77
  @cached_property
@@ -91,9 +94,11 @@ class EmbedDownloader:
91
94
  return is_valid
92
95
 
93
96
  @cached_property
94
- def latest_version(self) -> str | None:
97
+ def latest_path_version(self) -> str | None:
95
98
  """Get the latest version of Python available for download."""
96
- version = self.get_latest_patch_version(self.version, self.arch, self.cache_dir)
99
+ version = self.get_latest_patch_version(
100
+ self.version, self.parent.arch, self.parent.cache_dir
101
+ )
97
102
  return version if "." in version else None
98
103
 
99
104
  def download_with_progress(self, url: str, dest_path: Path) -> None:
@@ -102,7 +107,7 @@ class EmbedDownloader:
102
107
 
103
108
  req = Request(url, headers={"User-Agent": USER_AGENT})
104
109
  try:
105
- with urlopen(req, timeout=self.timeout) as response:
110
+ with urlopen(req, timeout=self.parent.timeout) as response:
106
111
  total_size = int(response.headers.get("content-length", 0))
107
112
  dest_path.parent.mkdir(parents=True, exist_ok=True)
108
113
 
@@ -204,12 +209,12 @@ class EmbedDownloader:
204
209
 
205
210
  def get_official_url_template(self, arch: str | None = None) -> str:
206
211
  """Get official URL template based on architecture."""
207
- arch = arch or self.arch
212
+ arch = arch or self.parent.arch
208
213
  return f"https://www.python.org/ftp/python/{{version}}/python-{{version}}-embed-{arch}.zip"
209
214
 
210
215
  def get_mirror_url_templates(self, arch: str | None = None) -> list[str]:
211
216
  """Get mirror URL templates based on architecture."""
212
- arch = arch or self.arch
217
+ arch = arch or self.parent.arch
213
218
  return [
214
219
  f"https://mirrors.huaweicloud.com/python/{{version}}/python-{{version}}-embed-{arch}.zip",
215
220
  f"https://mirrors.aliyun.com/python-release/{{version}}/python-{{version}}-embed-{arch}.zip",
@@ -221,7 +226,7 @@ class EmbedDownloader:
221
226
  self, url: str, timeout: int | None = None, use_get: bool = False
222
227
  ) -> float:
223
228
  """Test URL speed by making a HEAD or GET request."""
224
- timeout = timeout or self.timeout
229
+ timeout = timeout or self.parent.timeout
225
230
  start_time = time.time()
226
231
 
227
232
  # First attempt HEAD request
@@ -268,7 +273,7 @@ class EmbedDownloader:
268
273
  ) -> list[str]:
269
274
  """Get list of URLs to test for speed."""
270
275
  version = version or self.version
271
- arch = arch or self.arch
276
+ arch = arch or self.parent.arch
272
277
  return [
273
278
  template.format(version=version)
274
279
  for template in [
@@ -285,8 +290,8 @@ class EmbedDownloader:
285
290
  ) -> list[str]:
286
291
  """Find all available URLs for downloading Python embeddable package, sorted by speed."""
287
292
  version = version or self.version
288
- arch = arch or self.arch
289
- timeout = timeout or self.timeout
293
+ arch = arch or self.parent.arch
294
+ timeout = timeout or self.parent.timeout
290
295
  official_url = self.get_official_url_template(arch).format(version=version)
291
296
 
292
297
  logger.debug("Testing mirror speeds...")
@@ -434,37 +439,24 @@ class EmbedDownloader:
434
439
  return self.resolve_patch_version(major_minor, arch, cache_dir, timeout)
435
440
 
436
441
 
437
- @dataclass(frozen=True)
442
+ @dataclass(frozen=False)
438
443
  class EmbedInstaller:
439
444
  """Class for installing Python embeddable package."""
440
445
 
441
446
  root_dir: Path
442
- version: str
443
- cache_dir: Path
444
- offline: bool
445
- skip_speed_test: bool
446
- keep_cache: bool
447
+ cache_dir: Path | None = field(default_factory=lambda: _DEFAULT_CACHE_DIR)
448
+ offline: bool = False
449
+ skip_speed_test: bool = False
447
450
  timeout: int = 5
448
451
 
449
452
  def __post_init__(self):
450
- pass
453
+ if not self.cache_dir:
454
+ object.__setattr__(self, "cache_dir", _DEFAULT_CACHE_DIR)
451
455
 
452
456
  @cached_property
453
457
  def solution(self) -> Solution:
454
458
  """Get the solution from the target directory."""
455
- return Solution.from_directory(self.root_dir)
456
-
457
- @cached_property
458
- def downloader(self) -> EmbedDownloader:
459
- """Get the embed downloader for the given version and architecture."""
460
- return EmbedDownloader(
461
- version=self.version,
462
- arch=self.arch,
463
- cache_dir=self.cache_dir,
464
- offline=self.offline,
465
- skip_speed_test=self.skip_speed_test,
466
- timeout=self.timeout,
467
- )
459
+ return Solution.from_directory(self.root_dir, update=True)
468
460
 
469
461
  @cached_property
470
462
  def arch(self) -> str:
@@ -484,15 +476,10 @@ class EmbedInstaller:
484
476
  """Get the path to python.exe in the target directory."""
485
477
  return self.runtime_dir / "python.exe"
486
478
 
487
- @cached_property
488
- def cache_file(self) -> Path:
489
- """Get the path to the cache file."""
490
- return self.downloader.cache_file
491
-
492
- def extract_package(self) -> bool:
479
+ def extract_package(self, downloader: EmbedDownloader) -> bool:
493
480
  """Extract package to target directory."""
494
- if not zipfile.is_zipfile(self.cache_file):
495
- logger.error(f"Invalid zip file: {self.cache_file}")
481
+ if not zipfile.is_zipfile(downloader.cache_file):
482
+ logger.error(f"Invalid zip file: {downloader.cache_file}")
496
483
  return False
497
484
 
498
485
  try:
@@ -500,50 +487,71 @@ class EmbedInstaller:
500
487
  self.runtime_dir.mkdir(parents=True, exist_ok=True)
501
488
 
502
489
  # First get file count for progress
503
- with zipfile.ZipFile(self.cache_file, "r") as zip_ref:
490
+ with zipfile.ZipFile(downloader.cache_file, "r") as zip_ref:
504
491
  file_count = len(zip_ref.namelist())
505
492
 
506
493
  # Extract the archive
507
- shutil.unpack_archive(str(self.cache_file), str(self.runtime_dir), "zip")
494
+ shutil.unpack_archive(
495
+ str(downloader.cache_file), str(self.runtime_dir), "zip"
496
+ )
508
497
  logger.info(f"Extracted {file_count} files")
509
498
  return True
510
499
  except (zipfile.BadZipFile, OSError) as e:
511
500
  logger.error(f"Failed to extract: {e}")
512
501
  return False
513
502
 
514
- def cleanup_cache(self) -> None:
515
- """Clean up cache file if not keeping it."""
516
- if not self.keep_cache and not self.offline:
517
- try:
518
- if self.cache_file.exists():
519
- logger.debug(f"Cleaning up cache: {self.cache_file}")
520
- self.cache_file.unlink()
521
- except OSError as e:
522
- logger.warning(f"Failed to clean up cache file: {e}")
523
-
524
- def install_package(self) -> bool:
503
+ def run(self) -> bool:
525
504
  """Install Python embeddable package to target directory using embedded methods."""
526
505
  # Download the package (latest_version is resolved automatically during download)
527
- if not self.downloader.download():
528
- logger.error("Failed to download Python embeddable package")
529
- return False
506
+ results = []
507
+ total_processed = 0
508
+ extracted = False # Track if we've already extracted for any project
509
+
510
+ for project in self.solution.projects.values():
511
+ if not project.min_python_version:
512
+ logger.warning(
513
+ f"Project {project.name} does not have a minimum Python version"
514
+ )
515
+ continue
516
+
517
+ logger.info(f"Processing project: `{project.name}`")
518
+ total_processed += 1
519
+ downloader = EmbedDownloader(
520
+ parent=self,
521
+ version=project.min_python_version or _DEFAULT_PYTHON_VER,
522
+ )
530
523
 
531
- # Extract the package to runtime directory
532
- if not self.extract_package():
533
- logger.error("Failed to extract Python embeddable package")
534
- return False
524
+ if not downloader.download():
525
+ logger.error("Failed to download Python embeddable package")
526
+ continue
527
+
528
+ # Extract the package to runtime directory only once
529
+ # All projects share the same embed Python runtime
530
+ if not extracted:
531
+ if self.python_exe_path.exists():
532
+ logger.info(
533
+ f"Python runtime already exists at {self.runtime_dir}, skipping extraction"
534
+ )
535
+ extracted = True
536
+ elif self.extract_package(downloader):
537
+ extracted = True
538
+ else:
539
+ logger.error("Failed to extract Python embeddable package")
540
+ continue
535
541
 
536
- # Clean up cache if not keeping it
537
- self.cleanup_cache()
542
+ # Verify installation by checking for python.exe
543
+ results.append(self.python_exe_path.exists())
538
544
 
539
- # Verify installation by checking for python.exe
540
- success = self.python_exe_path.exists()
541
- if success:
542
- logger.info(f"Installation completed, python.exe: {self.python_exe_path}")
543
- else:
544
- logger.error("Installation failed, python.exe not found")
545
+ if total_processed == 0:
546
+ logger.error("No projects with minimum Python version found to process")
547
+ return False
545
548
 
546
- return success
549
+ if all(results) and len(results) > 0:
550
+ logger.info("Python embeddable package installed successfully")
551
+ return True
552
+ else:
553
+ logger.error("Failed to install Python embeddable package")
554
+ return False
547
555
 
548
556
 
549
557
  def parse_args() -> argparse.Namespace:
@@ -570,7 +578,7 @@ def parse_args() -> argparse.Namespace:
570
578
  "--version",
571
579
  "-v",
572
580
  type=str,
573
- default="3.8.10",
581
+ default=_DEFAULT_PYTHON_VER,
574
582
  help="Python version to install (default: 3.8.10)",
575
583
  )
576
584
  parser.add_argument(
@@ -580,13 +588,6 @@ def parse_args() -> argparse.Namespace:
580
588
  default=str(_DEFAULT_CACHE_DIR),
581
589
  help="Cache directory for downloaded files",
582
590
  )
583
- parser.add_argument(
584
- "--keep-cache",
585
- "-k",
586
- action="store_true",
587
- default=True,
588
- help="Keep downloaded cache files",
589
- )
590
591
  parser.add_argument(
591
592
  "--skip-speed-test",
592
593
  "-s",
@@ -617,13 +618,11 @@ def main() -> None:
617
618
 
618
619
  if not EmbedInstaller(
619
620
  root_dir=Path(args.directory),
620
- version=args.version,
621
621
  cache_dir=cache_dir,
622
622
  offline=args.offline,
623
623
  skip_speed_test=args.skip_speed_test,
624
- keep_cache=args.keep_cache,
625
624
  timeout=args.timeout,
626
- ).install_package():
625
+ ).run():
627
626
  exit(1)
628
627
 
629
628
  logger.info(f"Installation completed in {time.perf_counter() - t0:.4f} seconds")