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.
@@ -16,6 +16,7 @@ import argparse
16
16
  import datetime
17
17
  import json
18
18
  import logging
19
+ import platform
19
20
  import re
20
21
  import sys
21
22
  import time
@@ -35,9 +36,12 @@ __all__ = ["Project", "Solution"]
35
36
  logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
36
37
  logger = logging.getLogger(__name__)
37
38
  cwd = Path.cwd()
39
+ is_windows = platform.system() == "Windows"
38
40
 
39
41
  # Precompiled regex for dependency name extraction (optimization)
40
42
  _DEP_NAME_PATTERN = re.compile(r"^([a-zA-Z0-9._-]+)")
43
+ _EXTRA_PATTERN = re.compile(r"\[([^\]]+)\]")
44
+ _VERSION_PATTERN = re.compile(r"[<>=!~].*$")
41
45
 
42
46
  # Qt-related keywords and dependencies for faster detection
43
47
  _QT_DEPENDENCIES: frozenset[str] = frozenset((
@@ -59,6 +63,73 @@ _GUI_KEYWORDS: frozenset[str] = frozenset(("gui", "desktop"))
59
63
  _REQUIRED_ATTRS: frozenset[str] = frozenset(("name", "version", "description"))
60
64
 
61
65
 
66
+ @dataclass(frozen=True)
67
+ class Dependency:
68
+ """Represents a Python package dependency."""
69
+
70
+ name: str = ""
71
+ version: str | None = None
72
+ extras: set[str] = field(default_factory=set)
73
+ requires: set[str] = field(default_factory=set)
74
+
75
+ @staticmethod
76
+ def from_str(dep_str: str) -> Dependency:
77
+ """Create a Dependency instance from a dependency string.
78
+
79
+ Args:
80
+ dep_str (str): The dependency string in the format "name[extras]version".
81
+
82
+ Returns:
83
+ Dependency: The created Dependency instance.
84
+ """
85
+ # Parse the dependency string to extract name, extras, and version
86
+ name = ""
87
+ version = None # Use None initially, will be set if version is found
88
+ extras = set()
89
+
90
+ # First, extract extras if present (text within square brackets)
91
+ extras_match = _EXTRA_PATTERN.search(dep_str)
92
+ if extras_match:
93
+ extras_str = extras_match.group(1)
94
+ extras = {extra.strip() for extra in extras_str.split(",")}
95
+ # Remove the extras part from the string for further processing
96
+ dep_str = dep_str[: extras_match.start()] + dep_str[extras_match.end() :]
97
+
98
+ # Extract version specifier (starts with comparison operators like >=, <=, ==, etc.)
99
+ version_match = _VERSION_PATTERN.search(dep_str)
100
+ if version_match:
101
+ version = version_match.group()
102
+ dep_str = dep_str[: version_match.start()].strip()
103
+
104
+ # Remaining part is the name
105
+ name = dep_str.strip()
106
+
107
+ return Dependency(name=name, version=version, extras=extras, requires=set())
108
+
109
+ def __post_init__(self):
110
+ # Normalize the name after initialization
111
+ # This ensures that names like "Requests-OAuthLib" become "requests_oauthlib"
112
+ object.__setattr__(self, "name", self.name.lower().replace("-", "_"))
113
+
114
+ @cached_property
115
+ def normalized_name(self) -> str:
116
+ """Return the normalized name of the dependency (same as name since it's already normalized)."""
117
+ return self.name
118
+
119
+ def __str__(self) -> str:
120
+ """String representation of dependency."""
121
+ # Pre-cache version to avoid repeated evaluation
122
+ version_str = self.version or ""
123
+ if self.extras:
124
+ # Pre-cache sorted extras to avoid repeated sorting
125
+ sorted_extras = ",".join(sorted(self.extras))
126
+ return f"{self.name}[{sorted_extras}]{version_str}"
127
+ return f"{self.name}{version_str}"
128
+
129
+ def __hash__(self) -> int:
130
+ return hash((self.name, self.version, frozenset(self.extras)))
131
+
132
+
62
133
  @dataclass(frozen=True)
63
134
  class Project:
64
135
  """Represents a single Python project parsed from a pyproject.toml file.
@@ -84,6 +155,8 @@ class Project:
84
155
  urls: Dictionary of project URLs (homepage, repository, etc.)
85
156
  build_backend: Build backend system used (e.g., "setuptools.build_meta")
86
157
  requires: List of build system requirements
158
+ toml_path: Path to the pyproject.toml file
159
+ solution_root_dir: Root directory of the solution (for multi-project setups)
87
160
  """
88
161
 
89
162
  name: str
@@ -102,11 +175,99 @@ class Project:
102
175
  urls: dict[str, str]
103
176
  build_backend: str
104
177
  requires: list[str]
178
+ toml_path: Path
179
+ solution_root_dir: Path | None = None
180
+
181
+ @cached_property
182
+ def min_python_version(self) -> str | None:
183
+ """Extract the minimum Python version from requires_python.
184
+
185
+ Parses the requires_python string to extract the minimum version requirement.
186
+ Supports formats like ">=3.8", ">3.7", "~=3.9", etc. Returns None if no
187
+ minimum version is specified or if the format is unrecognized.
188
+
189
+ Returns:
190
+ The minimum Python version as a string, or None if not found.
191
+ """
192
+ if not self.requires_python:
193
+ return None
194
+
195
+ # Pattern to match minimum Python version requirements
196
+ # Examples: >=3.8, >3.7, ~=3.9, ==3.8.*, etc.
197
+ patterns = [
198
+ r">=(\d+\.\d+(?:\.\d+)?)", # >=3.8, >=3.8.1
199
+ r">(\d+\.\d+(?:\.\d+)?)", # >3.7
200
+ r"~=?(\d+\.\d+(?:\.\d+)?)", # ~=3.9, ~3.9
201
+ r"==?(\d+\.\d+(?:\.\d+)?)(?:\.\*)?", # ==3.8, ==3.8.*, =3.8
202
+ ]
203
+
204
+ for pattern in patterns:
205
+ match = re.search(pattern, self.requires_python)
206
+ if match:
207
+ return match.group(1)
208
+
209
+ return None
210
+
211
+ @cached_property
212
+ def root_dir(self) -> Path:
213
+ """Return the root directory of the project."""
214
+ if not self.toml_path.is_file():
215
+ # Return current directory if toml_path is not set or invalid
216
+ # This can happen when Project is created with default values
217
+ return Path()
218
+
219
+ return self.toml_path.parent
220
+
221
+ @cached_property
222
+ def dist_dir(self) -> Path:
223
+ """Return the distribution directory of the project.
224
+
225
+ In multi-project solutions, returns solution_root_dir/dist.
226
+ In single-project setups, returns project_root_dir/dist.
227
+ """
228
+ if self.solution_root_dir:
229
+ dist_dir = self.solution_root_dir / "dist"
230
+ else:
231
+ dist_dir = self.root_dir / "dist"
232
+ dist_dir.mkdir(parents=True, exist_ok=True)
233
+ return dist_dir
234
+
235
+ @cached_property
236
+ def extension(self) -> str:
237
+ return ".exe" if is_windows else ""
238
+
239
+ @cached_property
240
+ def exe_name(self) -> str:
241
+ """Return the executable name of the project."""
242
+ return f"{self.name}{self.extension}"
243
+
244
+ @cached_property
245
+ def exe_path(self) -> Path:
246
+ """Return the executable path of the project."""
247
+ return self.dist_dir / f"{self.name}{self.extension}"
248
+
249
+ @cached_property
250
+ def runtime_dir(self) -> Path:
251
+ """Return the runtime directory of the project."""
252
+ runtime_dir = self.dist_dir / "runtime"
253
+ runtime_dir.mkdir(parents=True, exist_ok=True)
254
+ return runtime_dir
255
+
256
+ @cached_property
257
+ def lib_dir(self) -> Path:
258
+ """Return the lib directory of the project."""
259
+ lib_dir = self.dist_dir / "site-packages"
260
+ lib_dir.mkdir(parents=True, exist_ok=True)
261
+ return lib_dir
105
262
 
106
263
  @cached_property
107
264
  def normalized_name(self) -> str:
108
265
  return self.name.replace("-", "_")
109
266
 
267
+ @cached_property
268
+ def converted_dependencies(self) -> set[Dependency]:
269
+ return {Dependency.from_str(dep) for dep in self.dependencies}
270
+
110
271
  @cached_property
111
272
  def dep_names(self) -> set[str]:
112
273
  """Extract normalized dependency names from the dependencies list.
@@ -118,7 +279,6 @@ class Project:
118
279
  Returns:
119
280
  Set of normalized dependency package names.
120
281
  """
121
- # Use set comprehension directly to avoid intermediate list
122
282
  return {
123
283
  match.group(1)
124
284
  if (match := _DEP_NAME_PATTERN.match(main_part))
@@ -134,7 +294,10 @@ class Project:
134
294
  Returns:
135
295
  Set of Qt-related dependency names found in the project.
136
296
  """
137
- return self.dep_names & _QT_DEPENDENCIES
297
+ # Pre-cache the sets to avoid attribute lookup overhead
298
+ dep_names_set = self.dep_names
299
+ qt_deps_set = _QT_DEPENDENCIES
300
+ return dep_names_set & qt_deps_set
138
301
 
139
302
  @cached_property
140
303
  def has_qt(self) -> bool:
@@ -147,7 +310,8 @@ class Project:
147
310
  @cached_property
148
311
  def is_gui(self) -> bool:
149
312
  # Use set intersection for O(1) lookup instead of O(n) with any()
150
- return bool(set(self.keywords) & _GUI_KEYWORDS)
313
+ # Also consider projects with Qt dependencies as GUI applications
314
+ return bool(set(self.keywords) & _GUI_KEYWORDS) or self.has_qt
151
315
 
152
316
  @cached_property
153
317
  def loader_type(self) -> str:
@@ -169,24 +333,46 @@ class Project:
169
333
  """
170
334
  if not toml_file.is_file():
171
335
  logger.error(f"{toml_file} does not exist")
172
- return Project.from_dict({})
336
+ return Project._from_empty_dict()
173
337
 
174
338
  try:
175
339
  with open(toml_file, "rb") as f:
176
340
  data = tomllib.load(f)
177
341
  except Exception as e:
178
342
  logger.error(f"Error parsing {toml_file}: {e}")
179
- return Project.from_dict({})
343
+ return Project._from_empty_dict()
180
344
 
181
345
  if "project" not in data:
182
346
  logger.error(f"No project section in {toml_file}")
183
- return Project.from_dict({})
347
+ return Project._from_empty_dict()
184
348
 
185
349
  if "name" not in data["project"]:
186
350
  logger.error(f"No name in project section of {toml_file}")
187
- return Project.from_dict({})
351
+ return Project._from_empty_dict()
352
+
353
+ # Extract project data and build system data separately for performance
354
+ project_data = data["project"]
355
+ build_system_data = data.get("build-system", {})
356
+
357
+ # Merge data efficiently
358
+ merged_data = project_data.copy() # Start with project data
188
359
 
189
- return Project.from_dict({**data["project"], **data.get("build-system", {})})
360
+ # Handle TOML field names that use hyphens but Python attributes use underscores
361
+ if "requires-python" in merged_data:
362
+ merged_data["requires_python"] = merged_data.pop("requires-python")
363
+
364
+ merged_data.update({"toml_path": toml_file})
365
+ merged_data.update(build_system_data) # Add build system data
366
+
367
+ return Project._from_dict(merged_data)
368
+
369
+ def pack_source(self):
370
+ """Pack source code and resources to dist/src directory."""
371
+ pass
372
+
373
+ def pack_embed_python(self):
374
+ """Pack Python runtime to dist/runtime directory."""
375
+ pass
190
376
 
191
377
  @cached_property
192
378
  def raw_data(self) -> dict[str, Any]:
@@ -216,7 +402,12 @@ class Project:
216
402
  }
217
403
 
218
404
  @classmethod
219
- def from_dict(cls, data: dict[str, Any]) -> Project:
405
+ def _from_empty_dict(cls) -> Project:
406
+ """Create a Project instance from an empty dictionary."""
407
+ return cls._from_dict({})
408
+
409
+ @classmethod
410
+ def _from_dict(cls, data: dict[str, Any]) -> Project:
220
411
  """Create a Project instance from a dictionary of project attributes.
221
412
 
222
413
  Args:
@@ -242,6 +433,8 @@ class Project:
242
433
  urls=data.get("urls", {}),
243
434
  build_backend=data.get("build_backend", ""),
244
435
  requires=data.get("requires", []),
436
+ toml_path=data.get("toml_path", Path("")),
437
+ solution_root_dir=data.get("solution_root_dir"),
245
438
  )
246
439
 
247
440
 
@@ -268,27 +461,41 @@ class Solution:
268
461
  time_stamp: datetime.datetime = field(default_factory=datetime.datetime.now)
269
462
 
270
463
  def __repr__(self):
271
- return f"""
272
- <Solution root={self.root_dir}
273
- projects: {len(self.projects)}
274
- update: {self.update}
275
- time_used: {self.elapsed_time:.4f}s
276
- timestamp: {self.time_stamp}
277
- >"""
278
-
279
- @cached_property
280
- def elapsed_time(self) -> float:
281
- """Calculate and cache the elapsed time since start."""
282
- return time.perf_counter() - self.start_time
464
+ return (
465
+ f"<Solution(\n"
466
+ f" root_dir={self.root_dir!r},\n"
467
+ f" projects: {len(self.projects)},\n"
468
+ f" update={self.update!r},\n"
469
+ f" time_used={self.elapsed_time:.4f}s,\n"
470
+ f" timestamp={self.time_stamp!r}\n"
471
+ f")>"
472
+ )
283
473
 
284
474
  def __post_init__(self):
285
- logger.info(f"\t - Loaded {len(self.projects)} projects from {self.root_dir}")
286
475
  # Only log brief summary to avoid overly verbose output
287
476
  logger.info(
288
- f"\t - Solution created in {self.elapsed_time:.4f}s at {self.time_stamp}"
477
+ f"Solution: {len(self.projects)} projects from {self.root_dir}, "
478
+ f"created in {self.elapsed_time:.4f}s at {self.time_stamp:%Y-%m-%d %H:%M:%S}"
289
479
  )
290
480
  self._write_project_json()
291
481
 
482
+ @cached_property
483
+ def elapsed_time(self) -> float:
484
+ """Calculate and cache the elapsed time since start."""
485
+ return time.perf_counter() - self.start_time
486
+
487
+ @cached_property
488
+ def dependencies(self) -> set[str]:
489
+ """Get a set of all dependencies for all projects in the solution.
490
+
491
+ Returns:
492
+ Set of dependency package names.
493
+ """
494
+ # Use set comprehension for better performance
495
+ return {
496
+ dep for project in self.projects.values() for dep in project.dependencies
497
+ }
498
+
292
499
  @cached_property
293
500
  def json_file(self) -> Path:
294
501
  """Path to the cache file where project data is stored.
@@ -298,6 +505,69 @@ class Solution:
298
505
  """
299
506
  return self.root_dir / "projects.json"
300
507
 
508
+ def find_matching_projects(self, pattern: str) -> list[str]:
509
+ """Find all projects matching the given pattern (case-insensitive).
510
+
511
+ Args:
512
+ pattern: Pattern to match (substring, case-insensitive)
513
+
514
+ Returns:
515
+ List of matching project names
516
+ """
517
+ if not pattern:
518
+ return []
519
+
520
+ lower_pattern = pattern.lower()
521
+ return [name for name in self.projects if lower_pattern in name.lower()]
522
+
523
+ def resolve_project_name(self, project_name: str | None) -> str | None:
524
+ """Resolve project name with fuzzy matching support.
525
+
526
+ Resolution strategy:
527
+ 1. If project_name is None: auto-select if only one project exists
528
+ 2. Exact match: return if project name exists
529
+ 3. Fuzzy match: find projects containing the given substring (case-insensitive)
530
+ 4. If multiple fuzzy matches: return None (ambiguous)
531
+
532
+ Args:
533
+ project_name: Project name to resolve, or None for auto-selection
534
+
535
+ Returns:
536
+ Resolved project name, or None if resolution fails
537
+
538
+ Examples:
539
+ >>> solution.resolve_project_name(None) # Auto-select if single project
540
+ 'myproject'
541
+ >>> solution.resolve_project_name('docscan') # Exact match
542
+ 'docscan'
543
+ >>> solution.resolve_project_name('doc') # Fuzzy match
544
+ 'docscan'
545
+ >>> solution.resolve_project_name('scan') # Fuzzy match
546
+ 'docscan'
547
+ """
548
+ # Auto-select if only one project
549
+ if not project_name:
550
+ if len(self.projects) == 1:
551
+ return next(iter(self.projects.keys()))
552
+ return None
553
+
554
+ # Exact match (case-sensitive)
555
+ if project_name in self.projects:
556
+ return project_name
557
+
558
+ # Fuzzy match (case-insensitive substring matching)
559
+ lower_name = project_name.lower()
560
+ matches = [name for name in self.projects if lower_name in name.lower()]
561
+
562
+ if len(matches) == 1:
563
+ return matches[0]
564
+ elif len(matches) > 1:
565
+ # Multiple matches found - ambiguous
566
+ return None
567
+
568
+ # No match found
569
+ return None
570
+
301
571
  @classmethod
302
572
  def from_toml_files(
303
573
  cls, root_dir: Path, toml_files: list[Path], update: bool = False
@@ -322,6 +592,31 @@ class Solution:
322
592
  f"Warning: {toml_file} does not contain project information"
323
593
  )
324
594
  continue
595
+
596
+ # For multi-project solutions, set solution_root_dir
597
+ # Create new Project with solution_root_dir set
598
+ if len(toml_files) > 1:
599
+ project = Project(
600
+ name=project.name,
601
+ version=project.version,
602
+ description=project.description,
603
+ readme=project.readme,
604
+ requires_python=project.requires_python,
605
+ dependencies=project.dependencies,
606
+ optional_dependencies=project.optional_dependencies,
607
+ scripts=project.scripts,
608
+ entry_points=project.entry_points,
609
+ authors=project.authors,
610
+ license=project.license,
611
+ keywords=project.keywords,
612
+ classifiers=project.classifiers,
613
+ urls=project.urls,
614
+ build_backend=project.build_backend,
615
+ requires=project.requires,
616
+ toml_path=project.toml_path,
617
+ solution_root_dir=root_dir,
618
+ )
619
+
325
620
  projects[project.name] = project
326
621
 
327
622
  return cls(root_dir=root_dir, projects=projects, update=update)
@@ -347,16 +642,16 @@ class Solution:
347
642
  # Check if the value has a "project" section (from pyproject.toml parsing)
348
643
  if "project" in value:
349
644
  project_data = value.get("project", {})
350
- projects[key] = Project.from_dict(project_data)
645
+ projects[key] = Project._from_dict(project_data)
351
646
  else:
352
647
  # Check if the value contains direct project attributes
353
648
  # Use set intersection with module-level constant for faster attribute checking
354
649
  if value.keys() & _REQUIRED_ATTRS:
355
- projects[key] = Project.from_dict(value)
650
+ projects[key] = Project._from_dict(value)
356
651
  else:
357
652
  # No project section or recognizable project attributes found
358
653
  logger.warning(f"No project information found in {key}")
359
- projects[key] = Project.from_dict({})
654
+ projects[key] = Project._from_empty_dict()
360
655
  else:
361
656
  projects[key] = value
362
657
  except (TypeError, ValueError) as e:
@@ -441,8 +736,7 @@ class Solution:
441
736
  return
442
737
 
443
738
  try:
444
- # Convert Project objects to dictionaries for JSON serialization
445
- # Use dict comprehension for better performance
739
+ # Pre-cache raw_data access to avoid repeated property access
446
740
  serializable_data = {
447
741
  key: project_data.raw_data
448
742
  if isinstance(project_data, Project)
@@ -491,6 +785,12 @@ def create_parser() -> argparse.ArgumentParser:
491
785
 
492
786
 
493
787
  def main() -> None:
788
+ """Main entry point for the pyproject.toml parser tool.
789
+
790
+ Parses command line arguments and creates a Solution instance by scanning
791
+ the specified directory for pyproject.toml files. Uses cached data if
792
+ available unless the update flag is set.
793
+ """
494
794
  parser = create_parser()
495
795
  args = parser.parse_args()
496
796
 
@@ -0,0 +1 @@
1
+