mongo-pipebuilder 0.2.2__py3-none-any.whl → 0.3.0__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,6 +9,6 @@ Author: seligoroff
9
9
 
10
10
  from mongo_pipebuilder.builder import PipelineBuilder
11
11
 
12
- __version__ = "0.2.2"
12
+ __version__ = "0.3.0"
13
13
  __all__ = ["PipelineBuilder"]
14
14
 
@@ -6,6 +6,8 @@ Builder Pattern implementation for safe construction of MongoDB aggregation pipe
6
6
 
7
7
  Author: seligoroff
8
8
  """
9
+ import json
10
+ from pathlib import Path
9
11
  from typing import Any, Dict, List, Optional, Union
10
12
 
11
13
  # For compatibility with Python < 3.11
@@ -33,8 +35,7 @@ class PipelineBuilder:
33
35
  Self for method chaining
34
36
 
35
37
  Raises:
36
- TypeError: If conditions is not a dictionary
37
- ValueError: If conditions is None
38
+ TypeError: If conditions is None or not a dictionary
38
39
 
39
40
  Example:
40
41
  >>> builder.match({"status": "active", "age": {"$gte": 18}})
@@ -636,23 +637,15 @@ class PipelineBuilder:
636
637
  "Only one output stage is allowed."
637
638
  )
638
639
 
639
- # If $out exists, it must be the last stage
640
- if has_out:
641
- out_index = stage_types.index("$out")
642
- if out_index != len(stage_types) - 1:
643
- raise ValueError(
644
- f"$out stage must be the last stage in the pipeline. "
645
- f"Found at position {out_index + 1} of {len(stage_types)}."
646
- )
647
-
648
- # If $merge exists, it must be the last stage
649
- if has_merge:
650
- merge_index = stage_types.index("$merge")
651
- if merge_index != len(stage_types) - 1:
652
- raise ValueError(
653
- f"$merge stage must be the last stage in the pipeline. "
654
- f"Found at position {merge_index + 1} of {len(stage_types)}."
655
- )
640
+ # Check if $out or $merge exist and validate position
641
+ for stage_name in ["$out", "$merge"]:
642
+ if stage_name in stage_types:
643
+ stage_index = stage_types.index(stage_name)
644
+ if stage_index != len(stage_types) - 1:
645
+ raise ValueError(
646
+ f"{stage_name} stage must be the last stage in the pipeline. "
647
+ f"Found at position {stage_index + 1} of {len(stage_types)}."
648
+ )
656
649
 
657
650
  return True
658
651
 
@@ -669,7 +662,7 @@ class PipelineBuilder:
669
662
  >>> builder.get_stage_types()
670
663
  ['$match', '$limit']
671
664
  """
672
- return [list(stage.keys())[0] for stage in self._stages]
665
+ return [next(iter(stage)) for stage in self._stages]
673
666
 
674
667
  def has_stage(self, stage_type: str) -> bool:
675
668
  """
@@ -762,6 +755,106 @@ class PipelineBuilder:
762
755
  self._stages.insert(position, stage)
763
756
  return self
764
757
 
758
+ def get_stage_at(self, index: int) -> Dict[str, Any]:
759
+ """
760
+ Get a specific stage from the pipeline by index.
761
+
762
+ Args:
763
+ index: Zero-based index of the stage to retrieve
764
+
765
+ Returns:
766
+ Dictionary representing the stage at the given index
767
+
768
+ Raises:
769
+ IndexError: If index is out of range
770
+
771
+ Example:
772
+ >>> builder = PipelineBuilder()
773
+ >>> builder.match({"status": "active"}).limit(10)
774
+ >>> stage = builder.get_stage_at(0)
775
+ >>> stage
776
+ {"$match": {"status": "active"}}
777
+ """
778
+ if index < 0 or index >= len(self._stages):
779
+ raise IndexError(
780
+ f"Index {index} out of range [0, {len(self._stages)}]"
781
+ )
782
+ return self._stages[index].copy()
783
+
784
+ def pretty_print(self, indent: int = 2, ensure_ascii: bool = False) -> str:
785
+ """
786
+ Return a formatted JSON string representation of the pipeline.
787
+
788
+ Useful for debugging and understanding pipeline structure.
789
+
790
+ Args:
791
+ indent: Number of spaces for indentation (default: 2)
792
+ ensure_ascii: If False, non-ASCII characters are output as-is (default: False)
793
+
794
+ Returns:
795
+ Formatted JSON string of the pipeline
796
+
797
+ Example:
798
+ >>> builder = PipelineBuilder()
799
+ >>> builder.match({"status": "active"}).limit(10)
800
+ >>> print(builder.pretty_print())
801
+ [
802
+ {
803
+ "$match": {
804
+ "status": "active"
805
+ }
806
+ },
807
+ {
808
+ "$limit": 10
809
+ }
810
+ ]
811
+ """
812
+ return json.dumps(self._stages, indent=indent, ensure_ascii=ensure_ascii)
813
+
814
+ def to_json_file(
815
+ self,
816
+ filepath: Union[str, Path],
817
+ indent: int = 2,
818
+ ensure_ascii: bool = False,
819
+ metadata: Optional[Dict[str, Any]] = None,
820
+ ) -> None:
821
+ """
822
+ Save the pipeline to a JSON file.
823
+
824
+ Useful for debugging, comparison with other pipelines, or versioning.
825
+
826
+ Args:
827
+ filepath: Path to the output JSON file (str or Path)
828
+ indent: Number of spaces for indentation (default: 2)
829
+ ensure_ascii: If False, non-ASCII characters are output as-is (default: False)
830
+ metadata: Optional metadata to include in the JSON file
831
+
832
+ Raises:
833
+ IOError: If file cannot be written
834
+
835
+ Example:
836
+ >>> builder = PipelineBuilder()
837
+ >>> builder.match({"status": "active"}).limit(10)
838
+ >>> builder.to_json_file("debug_pipeline.json")
839
+
840
+ >>> # With metadata
841
+ >>> builder.to_json_file(
842
+ ... "pipeline.json",
843
+ ... metadata={"version": "1.0", "author": "developer"}
844
+ ... )
845
+ """
846
+ filepath = Path(filepath)
847
+ filepath.parent.mkdir(parents=True, exist_ok=True)
848
+
849
+ output: Dict[str, Any] = {
850
+ "pipeline": self._stages,
851
+ }
852
+ if metadata:
853
+ output["metadata"] = metadata
854
+
855
+ with open(filepath, "w", encoding="utf-8") as f:
856
+ json.dump(output, f, indent=indent, ensure_ascii=ensure_ascii)
857
+
765
858
  def build(self) -> List[Dict[str, Any]]:
766
859
  """
767
860
  Return the completed pipeline.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mongo-pipebuilder
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Type-safe, fluent MongoDB aggregation pipeline builder
5
5
  Author-email: seligoroff <seligoroff@gmail.com>
6
6
  License: MIT
@@ -28,6 +28,12 @@ Dynamic: license-file
28
28
 
29
29
  # mongo-pipebuilder
30
30
 
31
+ [![PyPI version](https://badge.fury.io/py/mongo-pipebuilder.svg)](https://badge.fury.io/py/mongo-pipebuilder)
32
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
35
+ [![Test Coverage](https://img.shields.io/badge/coverage-96%25-green.svg)](https://github.com/seligoroff/mongo-pipebuilder)
36
+
31
37
  Type-safe, fluent MongoDB aggregation pipeline builder for Python.
32
38
 
33
39
  ## Overview
@@ -36,11 +42,11 @@ Type-safe, fluent MongoDB aggregation pipeline builder for Python.
36
42
 
37
43
  ## Features
38
44
 
39
- - **Type-safe**: Full type hints support with IDE autocomplete
40
- - **Fluent interface**: Chain methods for readable, maintainable code
41
- - **Zero dependencies**: Pure Python, lightweight package
42
- - **Extensible**: Easy to add custom stages via `add_stage()`
43
- - **Well tested**: Comprehensive test suite with 96%+ coverage
45
+ - **Type-safe**: Full type hints support with IDE autocomplete
46
+ - **Fluent interface**: Chain methods for readable, maintainable code
47
+ - **Zero dependencies**: Pure Python, lightweight package
48
+ - **Extensible**: Easy to add custom stages via `add_stage()`
49
+ - **Well tested**: Comprehensive test suite with 96%+ coverage
44
50
 
45
51
  ## Installation
46
52
 
@@ -259,6 +265,22 @@ group_index = stage_types.index("$group")
259
265
  builder.insert_at(group_index, {"$addFields": {"x": 1}})
260
266
  ```
261
267
 
268
+ ##### `copy() -> PipelineBuilder`
269
+
270
+ Creates an independent copy of the builder with current stages. Useful for creating immutable variants and composing pipelines.
271
+
272
+ ```python
273
+ builder1 = PipelineBuilder().match({"status": "active"})
274
+ builder2 = builder1.copy()
275
+ builder2.limit(10)
276
+
277
+ # Original unchanged
278
+ assert len(builder1) == 1
279
+ assert len(builder2) == 2
280
+ ```
281
+
282
+ See [Composing and Reusing Pipelines](#composing-and-reusing-pipelines) for practical examples.
283
+
262
284
  ##### `validate() -> bool`
263
285
 
264
286
  Validates the pipeline before execution. Checks that:
@@ -275,6 +297,54 @@ builder.add_stage({"$out": "output"}).match({"status": "active"})
275
297
  builder.validate() # Raises ValueError: $out stage must be the last stage
276
298
  ```
277
299
 
300
+ ##### `get_stage_at(index: int) -> Dict[str, Any]`
301
+
302
+ Gets a specific stage from the pipeline by index. Returns a copy of the stage.
303
+
304
+ ```python
305
+ builder = PipelineBuilder()
306
+ builder.match({"status": "active"}).limit(10)
307
+ stage = builder.get_stage_at(0) # Returns {"$match": {"status": "active"}}
308
+ ```
309
+
310
+ ##### `pretty_print(indent: int = 2, ensure_ascii: bool = False) -> str`
311
+
312
+ Returns a formatted JSON string representation of the pipeline. Useful for debugging.
313
+
314
+ ```python
315
+ builder = PipelineBuilder()
316
+ builder.match({"status": "active"}).limit(10)
317
+ print(builder.pretty_print())
318
+ # [
319
+ # {
320
+ # "$match": {
321
+ # "status": "active"
322
+ # }
323
+ # },
324
+ # {
325
+ # "$limit": 10
326
+ # }
327
+ # ]
328
+ ```
329
+
330
+ ##### `to_json_file(filepath: Union[str, Path], indent: int = 2, ensure_ascii: bool = False, metadata: Optional[Dict[str, Any]] = None) -> None`
331
+
332
+ Saves the pipeline to a JSON file. Useful for debugging, comparison, or versioning.
333
+
334
+ ```python
335
+ builder = PipelineBuilder()
336
+ builder.match({"status": "active"}).limit(10)
337
+
338
+ # Basic usage
339
+ builder.to_json_file("debug_pipeline.json")
340
+
341
+ # With metadata
342
+ builder.to_json_file(
343
+ "pipeline.json",
344
+ metadata={"version": "1.0", "author": "developer"}
345
+ )
346
+ ```
347
+
278
348
  ##### `build() -> List[Dict[str, Any]]`
279
349
 
280
350
  Returns the complete pipeline as a list of stage dictionaries.
@@ -340,6 +410,150 @@ pipeline = (
340
410
  )
341
411
  ```
342
412
 
413
+ ### Composing and Reusing Pipelines
414
+
415
+ The `copy()` method allows you to create immutable variants of pipelines, enabling safe composition and reuse. This is useful when you need to:
416
+ - Create multiple variants from a base pipeline
417
+ - Compose pipelines functionally
418
+ - Cache base pipelines safely
419
+ - Pass pipelines to functions without side effects
420
+
421
+ #### Example: Building Multiple Variants from a Base Pipeline
422
+
423
+ ```python
424
+ from mongo_pipebuilder import PipelineBuilder
425
+
426
+ # Base pipeline with common filtering and joining
427
+ base_pipeline = (
428
+ PipelineBuilder()
429
+ .match({"status": "published", "deleted": False})
430
+ .lookup(
431
+ from_collection="authors",
432
+ local_field="authorId",
433
+ foreign_field="_id",
434
+ as_field="author"
435
+ )
436
+ .unwind("author", preserve_null_and_empty_arrays=True)
437
+ .project({
438
+ "title": 1,
439
+ "authorName": "$author.name",
440
+ "publishedAt": 1
441
+ })
442
+ )
443
+
444
+ # Create variants with different sorting and limits
445
+ recent_posts = base_pipeline.copy().sort({"publishedAt": -1}).limit(10).build()
446
+ popular_posts = base_pipeline.copy().sort({"views": -1}).limit(5).build()
447
+ author_posts = base_pipeline.copy().match({"authorName": "John Doe"}).build()
448
+
449
+ # Base pipeline remains unchanged
450
+ assert len(base_pipeline) == 4 # Still has 4 stages
451
+ ```
452
+
453
+ #### Example: Functional Composition Pattern
454
+
455
+ ```python
456
+ def add_pagination(builder, page: int, page_size: int = 10):
457
+ """Add pagination to a pipeline."""
458
+ return builder.copy().skip(page * page_size).limit(page_size)
459
+
460
+ def add_sorting(builder, sort_field: str, ascending: bool = True):
461
+ """Add sorting to a pipeline."""
462
+ return builder.copy().sort({sort_field: 1 if ascending else -1})
463
+
464
+ # Compose pipelines functionally
465
+ base = PipelineBuilder().match({"status": "active"})
466
+
467
+ # Create different variants
468
+ page1 = add_pagination(add_sorting(base, "createdAt"), page=0)
469
+ page2 = add_pagination(add_sorting(base, "createdAt"), page=1)
470
+ sorted_by_name = add_sorting(base, "name", ascending=True)
471
+
472
+ # All variants are independent
473
+ assert len(base) == 1 # Base unchanged
474
+ assert len(page1) == 3 # match + sort + skip + limit
475
+ ```
476
+
477
+ #### Example: Caching Base Pipelines
478
+
479
+ ```python
480
+ from functools import lru_cache
481
+
482
+ @lru_cache(maxsize=100)
483
+ def get_base_pipeline(user_id: str):
484
+ """Cache base pipeline for a user."""
485
+ return (
486
+ PipelineBuilder()
487
+ .match({"userId": user_id, "status": "active"})
488
+ .lookup(
489
+ from_collection="profiles",
490
+ local_field="userId",
491
+ foreign_field="_id",
492
+ as_field="profile"
493
+ )
494
+ )
495
+
496
+ # Reuse cached base pipeline with different modifications
497
+ user_id = "12345"
498
+ base = get_base_pipeline(user_id)
499
+
500
+ # Create multiple queries from cached base
501
+ recent = base.copy().sort({"createdAt": -1}).limit(10).build()
502
+ by_category = base.copy().match({"category": "tech"}).build()
503
+ with_stats = base.copy().group({"_id": "$category"}, {"count": {"$sum": 1}}).build()
504
+
505
+ # Base pipeline is safely cached and reused
506
+ ```
507
+
508
+ #### Example: Pipeline Factories
509
+
510
+ ```python
511
+ class PipelineFactory:
512
+ """Factory for creating common pipeline patterns."""
513
+
514
+ @staticmethod
515
+ def base_article_pipeline():
516
+ """Base pipeline for articles."""
517
+ return (
518
+ PipelineBuilder()
519
+ .match({"status": "published"})
520
+ .lookup(
521
+ from_collection="authors",
522
+ local_field="authorId",
523
+ foreign_field="_id",
524
+ as_field="author"
525
+ )
526
+ )
527
+
528
+ @staticmethod
529
+ def with_author_filter(builder, author_name: str):
530
+ """Add author filter to pipeline."""
531
+ return builder.copy().match({"author.name": author_name})
532
+
533
+ @staticmethod
534
+ def with_date_range(builder, start_date: str, end_date: str):
535
+ """Add date range filter to pipeline."""
536
+ return builder.copy().match({
537
+ "publishedAt": {"$gte": start_date, "$lte": end_date}
538
+ })
539
+
540
+ # Usage
541
+ base = PipelineFactory.base_article_pipeline()
542
+ johns_articles = PipelineFactory.with_author_filter(base, "John Doe")
543
+ recent_johns = PipelineFactory.with_date_range(
544
+ johns_articles,
545
+ start_date="2024-01-01",
546
+ end_date="2024-12-31"
547
+ ).sort({"publishedAt": -1}).limit(10).build()
548
+ ```
549
+
550
+ **Key Benefits:**
551
+ - Safe reuse: Base pipelines remain unchanged
552
+ - Functional composition: Build pipelines from smaller parts
553
+ - Caching friendly: Base pipelines can be safely cached
554
+ - No side effects: Functions can safely modify copies
555
+ - Thread-safe: Multiple threads can use copies independently
556
+
343
557
  ## Development
344
558
 
345
559
  ### Project Structure
@@ -374,3 +588,18 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for development guidelines.
374
588
  MIT License - see [LICENSE](LICENSE) file for details.
375
589
 
376
590
 
591
+
592
+
593
+
594
+
595
+
596
+
597
+
598
+
599
+
600
+
601
+
602
+
603
+
604
+
605
+
@@ -0,0 +1,7 @@
1
+ mongo_pipebuilder/__init__.py,sha256=pP27GA8G6dttP-gMq9uNCJoS66-cb3JJiVVdI340er4,336
2
+ mongo_pipebuilder/builder.py,sha256=oQxRYL9ycjYCv2ErP_YHz-Uoo2pPRaRBaaaCLEsL5Mo,30286
3
+ mongo_pipebuilder-0.3.0.dist-info/licenses/LICENSE,sha256=-ZkZpDLHDQAc-YBIojJ6eDsMwxwx5pRuQz3RHnl9Y8w,1104
4
+ mongo_pipebuilder-0.3.0.dist-info/METADATA,sha256=KkgWrj5TD22yDj915Jrri_JftYMEpTz6hXSeHKEM7mk,15850
5
+ mongo_pipebuilder-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ mongo_pipebuilder-0.3.0.dist-info/top_level.txt,sha256=wLn7H_v-qaNIws5FeBbKPZBCmYFYgFEhPaLjoCWcisc,18
7
+ mongo_pipebuilder-0.3.0.dist-info/RECORD,,
@@ -21,3 +21,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
23
 
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
@@ -1,7 +0,0 @@
1
- mongo_pipebuilder/__init__.py,sha256=VF9G-Gp0kabZMoEmeQnSnSqHT3hLqxlEHY-FBeXfnVA,336
2
- mongo_pipebuilder/builder.py,sha256=qvyQd1k9YbaIV0tRixQnsNr7yuE-_SSGN5tBSwDAk5E,27199
3
- mongo_pipebuilder-0.2.2.dist-info/licenses/LICENSE,sha256=xAHmf48PmIziXYIdaJzRYeYpXFUPIb70SsSPhAHdggY,1089
4
- mongo_pipebuilder-0.2.2.dist-info/METADATA,sha256=QZJAl4akVEiT9ucIaviwAwJuvTWFL9XyBEyKb_LI6jc,9127
5
- mongo_pipebuilder-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- mongo_pipebuilder-0.2.2.dist-info/top_level.txt,sha256=wLn7H_v-qaNIws5FeBbKPZBCmYFYgFEhPaLjoCWcisc,18
7
- mongo_pipebuilder-0.2.2.dist-info/RECORD,,