photo-stack-finder 0.1.7__py3-none-any.whl → 0.1.8__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.
Files changed (68) hide show
  1. orchestrator/__init__.py +2 -2
  2. orchestrator/app.py +6 -11
  3. orchestrator/build_pipeline.py +19 -21
  4. orchestrator/orchestrator_runner.py +11 -8
  5. orchestrator/pipeline_builder.py +126 -126
  6. orchestrator/pipeline_orchestrator.py +604 -604
  7. orchestrator/review_persistence.py +162 -162
  8. orchestrator/static/orchestrator.css +76 -76
  9. orchestrator/static/orchestrator.html +11 -5
  10. orchestrator/static/orchestrator.js +3 -1
  11. overlap_metrics/__init__.py +1 -1
  12. overlap_metrics/config.py +135 -135
  13. overlap_metrics/core.py +284 -284
  14. overlap_metrics/estimators.py +292 -292
  15. overlap_metrics/metrics.py +307 -307
  16. overlap_metrics/registry.py +99 -99
  17. overlap_metrics/utils.py +104 -104
  18. photo_compare/__init__.py +1 -1
  19. photo_compare/base.py +285 -285
  20. photo_compare/config.py +225 -225
  21. photo_compare/distance.py +15 -15
  22. photo_compare/feature_methods.py +173 -173
  23. photo_compare/file_hash.py +29 -29
  24. photo_compare/hash_methods.py +99 -99
  25. photo_compare/histogram_methods.py +118 -118
  26. photo_compare/pixel_methods.py +58 -58
  27. photo_compare/structural_methods.py +104 -104
  28. photo_compare/types.py +28 -28
  29. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/METADATA +21 -22
  30. photo_stack_finder-0.1.8.dist-info/RECORD +75 -0
  31. scripts/orchestrate.py +12 -10
  32. utils/__init__.py +4 -3
  33. utils/base_pipeline_stage.py +171 -171
  34. utils/base_ports.py +176 -176
  35. utils/benchmark_utils.py +823 -823
  36. utils/channel.py +74 -74
  37. utils/comparison_gates.py +40 -21
  38. utils/compute_benchmarks.py +355 -355
  39. utils/compute_identical.py +94 -24
  40. utils/compute_indices.py +235 -235
  41. utils/compute_perceptual_hash.py +127 -127
  42. utils/compute_perceptual_match.py +240 -240
  43. utils/compute_sha_bins.py +64 -20
  44. utils/compute_template_similarity.py +1 -1
  45. utils/compute_versions.py +483 -483
  46. utils/config.py +8 -5
  47. utils/data_io.py +83 -83
  48. utils/graph_context.py +44 -44
  49. utils/logger.py +2 -2
  50. utils/models.py +2 -2
  51. utils/photo_file.py +90 -91
  52. utils/pipeline_graph.py +334 -334
  53. utils/pipeline_stage.py +408 -408
  54. utils/plot_helpers.py +123 -123
  55. utils/ports.py +136 -136
  56. utils/progress.py +415 -415
  57. utils/report_builder.py +139 -139
  58. utils/review_types.py +55 -55
  59. utils/review_utils.py +10 -19
  60. utils/sequence.py +10 -8
  61. utils/sequence_clustering.py +1 -1
  62. utils/template.py +57 -57
  63. utils/template_parsing.py +71 -0
  64. photo_stack_finder-0.1.7.dist-info/RECORD +0 -74
  65. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/WHEEL +0 -0
  66. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/entry_points.txt +0 -0
  67. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/licenses/LICENSE +0 -0
  68. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/top_level.txt +0 -0
utils/report_builder.py CHANGED
@@ -1,139 +1,139 @@
1
- """Report generation utility with fluent API for consistent formatting.
2
-
3
- This module provides a ReportBuilder class that simplifies the creation of
4
- text-based reports with consistent formatting across the codebase.
5
-
6
- Usage:
7
- report = (
8
- ReportBuilder()
9
- .add_title("Benchmark Results")
10
- .add_section("Performance Metrics")
11
- .add_metric("Accuracy", 0.95, ".2%")
12
- .add_blank_line()
13
- .build()
14
- )
15
- """
16
-
17
- from pathlib import Path
18
- from typing import Any, Self
19
-
20
-
21
- class ReportBuilder:
22
- """Fluent API for building text reports with consistent formatting.
23
-
24
- Provides a chainable interface for adding formatted sections, metrics,
25
- and separators to text reports. All methods return self to enable
26
- method chaining.
27
-
28
- Example:
29
- >>> report = (
30
- ... ReportBuilder()
31
- ... .add_title("Results")
32
- ... .add_metric("Score", 0.85, ".2f")
33
- ... .build()
34
- ... )
35
- """
36
-
37
- def __init__(self) -> None:
38
- """Initialize an empty report builder."""
39
- self._lines: list[str] = []
40
-
41
- def add_title(self, text: str, width: int = 80) -> Self:
42
- """Add a centered title with separator lines above and below.
43
-
44
- Args:
45
- text: The title text to display
46
- width: Width of the separator lines (default: 80)
47
-
48
- Returns:
49
- Self for method chaining
50
- """
51
- self._lines.append("=" * width)
52
- self._lines.append(text.center(width))
53
- self._lines.append("=" * width)
54
- return self
55
-
56
- def add_section(self, header: str) -> Self:
57
- """Add a section header with a single underline.
58
-
59
- Args:
60
- header: The section header text
61
-
62
- Returns:
63
- Self for method chaining
64
- """
65
- self._lines.append("")
66
- self._lines.append(header)
67
- self._lines.append("-" * len(header))
68
- return self
69
-
70
- def add_metric(self, name: str, value: Any, format_spec: str = "") -> Self:
71
- """Add a formatted metric line.
72
-
73
- Args:
74
- name: The metric name (will be left-aligned)
75
- value: The metric value to format
76
- format_spec: Optional format specification (e.g., ".2f", ".2%")
77
-
78
- Returns:
79
- Self for method chaining
80
-
81
- Example:
82
- >>> builder.add_metric("Accuracy", 0.95, ".2%") # "Accuracy: 95.00%"
83
- """
84
- if format_spec:
85
- formatted_value = f"{value:{format_spec}}"
86
- else:
87
- formatted_value = str(value)
88
- self._lines.append(f"{name}: {formatted_value}")
89
- return self
90
-
91
- def add_blank_line(self) -> Self:
92
- """Add a blank line for spacing.
93
-
94
- Returns:
95
- Self for method chaining
96
- """
97
- self._lines.append("")
98
- return self
99
-
100
- def add_separator(self, char: str = "=", width: int = 80) -> Self:
101
- """Add a separator line.
102
-
103
- Args:
104
- char: The character to use for the separator (default: "=")
105
- width: Width of the separator line (default: 80)
106
-
107
- Returns:
108
- Self for method chaining
109
- """
110
- self._lines.append(char * width)
111
- return self
112
-
113
- def add_text(self, text: str) -> Self:
114
- """Add arbitrary text (can be multi-line).
115
-
116
- Args:
117
- text: The text to add (newlines will be preserved)
118
-
119
- Returns:
120
- Self for method chaining
121
- """
122
- self._lines.append(text)
123
- return self
124
-
125
- def build(self) -> str:
126
- """Build and return the complete report as a string.
127
-
128
- Returns:
129
- The formatted report with newline-separated lines
130
- """
131
- return "\n".join(self._lines)
132
-
133
- def save(self, path: Path) -> None:
134
- """Build the report and save it to a file.
135
-
136
- Args:
137
- path: The file path where the report should be saved
138
- """
139
- path.write_text(self.build(), encoding="utf-8")
1
+ """Report generation utility with fluent API for consistent formatting.
2
+
3
+ This module provides a ReportBuilder class that simplifies the creation of
4
+ text-based reports with consistent formatting across the codebase.
5
+
6
+ Usage:
7
+ report = (
8
+ ReportBuilder()
9
+ .add_title("Benchmark Results")
10
+ .add_section("Performance Metrics")
11
+ .add_metric("Accuracy", 0.95, ".2%")
12
+ .add_blank_line()
13
+ .build()
14
+ )
15
+ """
16
+
17
+ from pathlib import Path
18
+ from typing import Any, Self
19
+
20
+
21
+ class ReportBuilder:
22
+ """Fluent API for building text reports with consistent formatting.
23
+
24
+ Provides a chainable interface for adding formatted sections, metrics,
25
+ and separators to text reports. All methods return self to enable
26
+ method chaining.
27
+
28
+ Example:
29
+ >>> report = (
30
+ ... ReportBuilder()
31
+ ... .add_title("Results")
32
+ ... .add_metric("Score", 0.85, ".2f")
33
+ ... .build()
34
+ ... )
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ """Initialize an empty report builder."""
39
+ self._lines: list[str] = []
40
+
41
+ def add_title(self, text: str, width: int = 80) -> Self:
42
+ """Add a centered title with separator lines above and below.
43
+
44
+ Args:
45
+ text: The title text to display
46
+ width: Width of the separator lines (default: 80)
47
+
48
+ Returns:
49
+ Self for method chaining
50
+ """
51
+ self._lines.append("=" * width)
52
+ self._lines.append(text.center(width))
53
+ self._lines.append("=" * width)
54
+ return self
55
+
56
+ def add_section(self, header: str) -> Self:
57
+ """Add a section header with a single underline.
58
+
59
+ Args:
60
+ header: The section header text
61
+
62
+ Returns:
63
+ Self for method chaining
64
+ """
65
+ self._lines.append("")
66
+ self._lines.append(header)
67
+ self._lines.append("-" * len(header))
68
+ return self
69
+
70
+ def add_metric(self, name: str, value: Any, format_spec: str = "") -> Self:
71
+ """Add a formatted metric line.
72
+
73
+ Args:
74
+ name: The metric name (will be left-aligned)
75
+ value: The metric value to format
76
+ format_spec: Optional format specification (e.g., ".2f", ".2%")
77
+
78
+ Returns:
79
+ Self for method chaining
80
+
81
+ Example:
82
+ >>> builder.add_metric("Accuracy", 0.95, ".2%") # "Accuracy: 95.00%"
83
+ """
84
+ if format_spec:
85
+ formatted_value = f"{value:{format_spec}}"
86
+ else:
87
+ formatted_value = str(value)
88
+ self._lines.append(f"{name}: {formatted_value}")
89
+ return self
90
+
91
+ def add_blank_line(self) -> Self:
92
+ """Add a blank line for spacing.
93
+
94
+ Returns:
95
+ Self for method chaining
96
+ """
97
+ self._lines.append("")
98
+ return self
99
+
100
+ def add_separator(self, char: str = "=", width: int = 80) -> Self:
101
+ """Add a separator line.
102
+
103
+ Args:
104
+ char: The character to use for the separator (default: "=")
105
+ width: Width of the separator line (default: 80)
106
+
107
+ Returns:
108
+ Self for method chaining
109
+ """
110
+ self._lines.append(char * width)
111
+ return self
112
+
113
+ def add_text(self, text: str) -> Self:
114
+ """Add arbitrary text (can be multi-line).
115
+
116
+ Args:
117
+ text: The text to add (newlines will be preserved)
118
+
119
+ Returns:
120
+ Self for method chaining
121
+ """
122
+ self._lines.append(text)
123
+ return self
124
+
125
+ def build(self) -> str:
126
+ """Build and return the complete report as a string.
127
+
128
+ Returns:
129
+ The formatted report with newline-separated lines
130
+ """
131
+ return "\n".join(self._lines)
132
+
133
+ def save(self, path: Path) -> None:
134
+ """Build the report and save it to a file.
135
+
136
+ Args:
137
+ path: The file path where the report should be saved
138
+ """
139
+ path.write_text(self.build(), encoding="utf-8")
utils/review_types.py CHANGED
@@ -1,55 +1,55 @@
1
- """Type definitions for review decision persistence."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Literal, TypedDict
6
-
7
- # Photo identifier using content hash + path (stable across runs)
8
- PhotoIdentifier = tuple[str, str] # (sha256, relative_path)
9
-
10
-
11
- class IdenticalDecision(TypedDict):
12
- """Decision for an identical group."""
13
-
14
- type: Literal["identical"]
15
- group_id: str # SHA256 of sorted photo sha256s
16
- timestamp: str # ISO format
17
- user: str
18
- action: Literal["keep_all", "keep_exemplar", "delete_all", "custom"]
19
- kept_photos: list[PhotoIdentifier] # Photos to keep (empty if keep_all)
20
- deleted_photos: list[PhotoIdentifier] # Photos to delete (empty if keep_all)
21
-
22
-
23
- class SequenceDecision(TypedDict):
24
- """Decision for a sequence similarity group."""
25
-
26
- type: Literal["sequences"]
27
- group_id: str # SHA256 of template + sorted photo sha256s
28
- timestamp: str # ISO format
29
- user: str
30
- action: Literal["approved", "rejected"]
31
- sequence_selections: dict[str, bool] # sequence_name -> included
32
- deleted_photos: list[PhotoIdentifier] # Individual photos marked for deletion
33
- deleted_rows: list[int] # Row positions marked for deletion
34
- deleted_sequences: list[int] # Sequence indices marked for deletion
35
-
36
-
37
- class ReviewIndexEntry(TypedDict):
38
- """Entry in the review index (loaded from JSONL)."""
39
-
40
- group_id: str
41
- decision_type: Literal["identical", "sequences"]
42
- action: str
43
- timestamp: str
44
- user: str
45
-
46
-
47
- class DeletionIndexEntry(TypedDict):
48
- """Entry in the deletion index."""
49
-
50
- sha256: str
51
- path: str
52
- reason: str # "identical_group", "sequence_group", "individual"
53
- group_id: str
54
- timestamp: str
55
- user: str
1
+ """Type definitions for review decision persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, TypedDict
6
+
7
+ # Photo identifier using content hash + path (stable across runs)
8
+ PhotoIdentifier = tuple[str, str] # (sha256, relative_path)
9
+
10
+
11
+ class IdenticalDecision(TypedDict):
12
+ """Decision for an identical group."""
13
+
14
+ type: Literal["identical"]
15
+ group_id: str # SHA256 of sorted photo sha256s
16
+ timestamp: str # ISO format
17
+ user: str
18
+ action: Literal["keep_all", "keep_exemplar", "delete_all", "custom"]
19
+ kept_photos: list[PhotoIdentifier] # Photos to keep (empty if keep_all)
20
+ deleted_photos: list[PhotoIdentifier] # Photos to delete (empty if keep_all)
21
+
22
+
23
+ class SequenceDecision(TypedDict):
24
+ """Decision for a sequence similarity group."""
25
+
26
+ type: Literal["sequences"]
27
+ group_id: str # SHA256 of template + sorted photo sha256s
28
+ timestamp: str # ISO format
29
+ user: str
30
+ action: Literal["approved", "rejected"]
31
+ sequence_selections: dict[str, bool] # sequence_name -> included
32
+ deleted_photos: list[PhotoIdentifier] # Individual photos marked for deletion
33
+ deleted_rows: list[int] # Row positions marked for deletion
34
+ deleted_sequences: list[int] # Sequence indices marked for deletion
35
+
36
+
37
+ class ReviewIndexEntry(TypedDict):
38
+ """Entry in the review index (loaded from JSONL)."""
39
+
40
+ group_id: str
41
+ decision_type: Literal["identical", "sequences"]
42
+ action: str
43
+ timestamp: str
44
+ user: str
45
+
46
+
47
+ class DeletionIndexEntry(TypedDict):
48
+ """Entry in the deletion index."""
49
+
50
+ sha256: str
51
+ path: str
52
+ reason: str # "identical_group", "sequence_group", "individual"
53
+ group_id: str
54
+ timestamp: str
55
+ user: str
utils/review_utils.py CHANGED
@@ -32,18 +32,16 @@ from .sequence import (
32
32
 
33
33
 
34
34
  def build_identical_group(eq_class: list[PhotoFile], exemplar_id: int) -> IdenticalGroup:
35
- """Create review data structure from a list of identical photos."""
35
+ """Create review data structure from a list of identical photos.
36
+
37
+ Uses pre-computed dimensions from PhotoFile (no file I/O required).
38
+ """
36
39
  # Create stable group_id from sorted photo IDs
37
40
  photo_ids: list[int] = sorted([pf.id for pf in eq_class])
38
41
  group_id: str = hashlib.sha256("".join(str(id) for id in photo_ids).encode()).hexdigest()
39
42
 
40
43
  photos: list[IdenticalPhoto] = []
41
44
  for pf in eq_class:
42
- # Get canonical dimensions (may trigger rotation detection if not cached)
43
- with pf.image_data() as img:
44
- width = img.get_width()
45
- height = img.get_height()
46
-
47
45
  # Production code: path should never be None
48
46
  assert pf.path is not None, f"Photo {pf.id} has None path in production code"
49
47
 
@@ -52,10 +50,10 @@ def build_identical_group(eq_class: list[PhotoFile], exemplar_id: int) -> Identi
52
50
  id=pf.id,
53
51
  path=str(pf.path),
54
52
  filename=pf.path.name,
55
- is_exemplar="IDENTICAL" in pf.cache,
53
+ is_exemplar="IDENTICAL" not in pf.cache,
56
54
  file_size=pf.size_bytes,
57
- width=width,
58
- height=height,
55
+ width=pf.width, # Use pre-computed dimension
56
+ height=pf.height, # Use pre-computed dimension
59
57
  )
60
58
  )
61
59
 
@@ -183,16 +181,9 @@ def _dataframe_to_group_dict(df: pd.DataFrame, reference: PhotoFileSeries) -> Se
183
181
  # In production, path is never None, but test fixtures need this
184
182
  filename = photo.path.name if photo.path is not None else f"test_photo_{photo.id}.jpg"
185
183
 
186
- # Get canonical dimensions (may trigger rotation detection if not cached)
187
- # For test fixtures with path=None, use placeholder values
188
- if photo.path is not None:
189
- with photo.image_data() as img:
190
- width = img.get_width()
191
- height = img.get_height()
192
- else:
193
- # Test fixture: estimate dimensions from pixels assuming square
194
- width = int(math.sqrt(photo.pixels))
195
- height = int(math.sqrt(photo.pixels))
184
+ # Use pre-computed dimensions (loaded during PhotoFile.__init__)
185
+ width = photo.width
186
+ height = photo.height
196
187
 
197
188
  photos.append(
198
189
  SequencePhoto(
utils/sequence.py CHANGED
@@ -1,10 +1,10 @@
1
- """Sequence data structures for photo deduplication pipeline.
1
+ """Sequence data structures for photo stack finding pipeline.
2
2
 
3
3
  This module provides dict-based data structures that replace pandas for type safety
4
4
  and pickle reliability, while maintaining minimal pandas usage for specific algorithms.
5
5
 
6
6
  Core Types:
7
- INDEX_T: Type alias for tuple[str, ...], used as multi-field index keys
7
+ INDEX_T: Type alias for tuple[str, ...] (from template_parsing), used as multi-field index keys
8
8
  PhotoFileSeries: dict[INDEX_T, PhotoFile] with template name and pd.Series-like API
9
9
  PhotoSequence: Hierarchical forest structure containing reference + similar sequences
10
10
 
@@ -54,12 +54,15 @@ import pandas as pd
54
54
  from .comparison_gates import GateSequence
55
55
  from .config import CONFIG
56
56
  from .photo_file import PhotoFile
57
+ from .template_parsing import INDEX_T
57
58
 
58
- # Type alias for multi-field index keys used in PhotoFileSeries
59
- # Each tuple element represents a field extracted from the filename pattern
60
- # Example: ("IMG", "001", "2024") for template "prefix_{P0}_{P1}_{P2}.jpg"
61
- # Variable-length tuple allows different sequences to have different numbers of fields
62
- INDEX_T = tuple[str, ...]
59
+ __all__ = [
60
+ "INDEX_T",
61
+ "PhotoFileSeries",
62
+ "PhotoSequence",
63
+ "count_forest_ref_photos",
64
+ "count_forest_total_photos",
65
+ ]
63
66
 
64
67
 
65
68
  class PhotoFileSeries(dict[INDEX_T, PhotoFile]):
@@ -844,6 +847,5 @@ def extend_reference_sequence(
844
847
  return result
845
848
 
846
849
 
847
-
848
850
  # Moved to sequence_clustering.py to avoid circular import with review_utils
849
851
  # Import from sequence_clustering instead: from .sequence_clustering import cluster_similar_sequences
@@ -1,4 +1,4 @@
1
- """Sequence clustering algorithm for photo deduplication pipeline.
1
+ """Sequence clustering algorithm for photo stack finding pipeline.
2
2
 
3
3
  This module provides the core clustering algorithm used by multiple pipeline stages
4
4
  (ComputeIndices, ComputeTemplateSimilarity, ComputePerceptualMatch) to group similar
utils/template.py CHANGED
@@ -1,57 +1,57 @@
1
- """Template utilities for partial string substitution.
2
-
3
- Provides template extraction and partial substitution using str.format_map().
4
- """
5
-
6
- from __future__ import annotations
7
-
8
-
9
- class DefaultDict(dict[str, str]):
10
- """Dictionary that returns placeholder for missing keys during format_map().
11
-
12
- This allows partial substitution of template strings, leaving unmatched
13
- placeholders in their original form.
14
-
15
- Example:
16
- >>> template = "IMG_{P0}_{P1}_{P2}"
17
- >>> values = {'P0': '1234', 'P1': '5678'}
18
- >>> result = template.format_map(DefaultDict(values))
19
- >>> print(result)
20
- IMG_1234_5678_{P2}
21
- """
22
-
23
- def __missing__(self, key: str) -> str:
24
- """Return the key wrapped in braces for missing keys.
25
-
26
- Args:
27
- key: The missing key
28
-
29
- Returns:
30
- String of the form "{key}"
31
- """
32
- return f"{{{key}}}"
33
-
34
-
35
- def partial_format(template: str, values: dict[str, str]) -> str:
36
- """Perform partial string substitution on a template.
37
-
38
- Substitutes available values and leaves missing placeholders unchanged.
39
-
40
- Args:
41
- template: Template string with {P0}, {P1}, etc. placeholders
42
- values: Dictionary of values to substitute
43
-
44
- Returns:
45
- Partially formatted string
46
-
47
- Example:
48
- >>> partial_format("IMG_{P0}_{P1}_{P2}", {'P0': '1234', 'P1': '5678'})
49
- 'IMG_1234_5678_{P2}'
50
-
51
- >>> partial_format("IMG_{P0}_{P1}", {'P0': '1234', 'P1': '5678'})
52
- 'IMG_1234_5678'
53
-
54
- >>> partial_format("IMG_{P0}_{P1}_{P2}", {})
55
- 'IMG_{P0}_{P1}_{P2}'
56
- """
57
- return template.format_map(DefaultDict(values))
1
+ """Template utilities for partial string substitution.
2
+
3
+ Provides template extraction and partial substitution using str.format_map().
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class DefaultDict(dict[str, str]):
10
+ """Dictionary that returns placeholder for missing keys during format_map().
11
+
12
+ This allows partial substitution of template strings, leaving unmatched
13
+ placeholders in their original form.
14
+
15
+ Example:
16
+ >>> template = "IMG_{P0}_{P1}_{P2}"
17
+ >>> values = {'P0': '1234', 'P1': '5678'}
18
+ >>> result = template.format_map(DefaultDict(values))
19
+ >>> print(result)
20
+ IMG_1234_5678_{P2}
21
+ """
22
+
23
+ def __missing__(self, key: str) -> str:
24
+ """Return the key wrapped in braces for missing keys.
25
+
26
+ Args:
27
+ key: The missing key
28
+
29
+ Returns:
30
+ String of the form "{key}"
31
+ """
32
+ return f"{{{key}}}"
33
+
34
+
35
+ def partial_format(template: str, values: dict[str, str]) -> str:
36
+ """Perform partial string substitution on a template.
37
+
38
+ Substitutes available values and leaves missing placeholders unchanged.
39
+
40
+ Args:
41
+ template: Template string with {P0}, {P1}, etc. placeholders
42
+ values: Dictionary of values to substitute
43
+
44
+ Returns:
45
+ Partially formatted string
46
+
47
+ Example:
48
+ >>> partial_format("IMG_{P0}_{P1}_{P2}", {'P0': '1234', 'P1': '5678'})
49
+ 'IMG_1234_5678_{P2}'
50
+
51
+ >>> partial_format("IMG_{P0}_{P1}", {'P0': '1234', 'P1': '5678'})
52
+ 'IMG_1234_5678'
53
+
54
+ >>> partial_format("IMG_{P0}_{P1}_{P2}", {})
55
+ 'IMG_{P0}_{P1}_{P2}'
56
+ """
57
+ return template.format_map(DefaultDict(values))