aggify 0.2.8__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: aggify
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: A MongoDB aggregation generator for Mongoengine
5
5
  Home-page: https://github.com/Aggify/aggify
6
6
  Author: SeYeD.Dev
@@ -27,7 +27,7 @@ Description-Content-Type: text/markdown
27
27
  [![Package version](https://img.shields.io/pypi/v/aggify?color=%2334D058&label=pypi%20package)](https://pypi.org/project/aggify)
28
28
  [![Downloads](https://img.shields.io/pypi/dm/aggify)](https://pypi.org/project/aggify)
29
29
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/aggify.svg?color=%2334D058)](https://pypi.org/project/aggify)
30
- [![Coverage](https://img.shields.io/codecov/c/github/Aggify/aggify)](https://coverage-badge.samuelcolvin.workers.dev/redirect/Aggify/aggify)
30
+ [![Coverage](https://img.shields.io/codecov/c/github/Aggify/aggify)](https://app.codecov.io/gh/Aggify/aggify)
31
31
  [![License](https://img.shields.io/github/license/Aggify/aggify.svg)](https://github.com/Aggify/aggify/blob/main/LICENSE)
32
32
  [![Contributors](https://img.shields.io/github/contributors/Aggify/aggify.svg)](https://github.com/Aggify/aggify/graphs/contributors)
33
33
  [![Telegram](https://img.shields.io/badge/-telegram-red?color=white&logo=telegram&logoColor=blue)](https://t.me/Aggify)
@@ -181,4 +181,3 @@ In the sample usage above, you can see how Aggify simplifies the construction of
181
181
  allowing you to chain filters, lookups, and other operations to build complex queries.
182
182
  For more details and examples, please refer to the documentation and codebase.
183
183
 
184
-
@@ -8,7 +8,7 @@
8
8
  [![Package version](https://img.shields.io/pypi/v/aggify?color=%2334D058&label=pypi%20package)](https://pypi.org/project/aggify)
9
9
  [![Downloads](https://img.shields.io/pypi/dm/aggify)](https://pypi.org/project/aggify)
10
10
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/aggify.svg?color=%2334D058)](https://pypi.org/project/aggify)
11
- [![Coverage](https://img.shields.io/codecov/c/github/Aggify/aggify)](https://coverage-badge.samuelcolvin.workers.dev/redirect/Aggify/aggify)
11
+ [![Coverage](https://img.shields.io/codecov/c/github/Aggify/aggify)](https://app.codecov.io/gh/Aggify/aggify)
12
12
  [![License](https://img.shields.io/github/license/Aggify/aggify.svg)](https://github.com/Aggify/aggify/blob/main/LICENSE)
13
13
  [![Contributors](https://img.shields.io/github/contributors/Aggify/aggify.svg)](https://github.com/Aggify/aggify/graphs/contributors)
14
14
  [![Telegram](https://img.shields.io/badge/-telegram-red?color=white&logo=telegram&logoColor=blue)](https://t.me/Aggify)
@@ -161,4 +161,3 @@ Mongoengine equivalent query:
161
161
  In the sample usage above, you can see how Aggify simplifies the construction of MongoDB aggregation pipelines by
162
162
  allowing you to chain filters, lookups, and other operations to build complex queries.
163
163
  For more details and examples, please refer to the documentation and codebase.
164
-
@@ -1,7 +1,7 @@
1
1
  import functools
2
- from typing import Any, Dict, Type, Union, List, Callable, TypeVar
2
+ from typing import Any, Dict, Type, Union, List, TypeVar, Callable
3
3
 
4
- from mongoengine import Document, EmbeddedDocument, fields
4
+ from mongoengine import Document, EmbeddedDocument, fields as mongoengine_fields
5
5
  from mongoengine.base import TopLevelDocumentMetaclass
6
6
 
7
7
  from aggify.compiler import F, Match, Q, Operators, Cond # noqa keep
@@ -85,25 +85,23 @@ class Aggify:
85
85
  """
86
86
 
87
87
  # Extract fields to keep and check if _id should be deleted
88
- to_keep_values = ["id"]
89
- delete_id = kwargs.get("id") == 0
88
+ to_keep_values = {"id"}
89
+ delete_id = kwargs.get("id") is not None
90
90
  projection = {}
91
91
 
92
92
  # Add missing fields to the base model
93
93
  for key, value in kwargs.items():
94
94
  if value == 1:
95
- to_keep_values.append(key)
95
+ to_keep_values.add(key)
96
96
  elif key not in self.base_model._fields and isinstance( # noqa
97
97
  kwargs[key], (str, dict)
98
- ): # noqa
99
- to_keep_values.append(key)
100
- self.base_model._fields[key] = fields.IntField() # noqa
98
+ ):
99
+ to_keep_values.add(key)
100
+ self.base_model._fields[key] = mongoengine_fields.IntField() # noqa
101
101
  projection[get_db_field(self.base_model, key)] = value # noqa
102
102
 
103
103
  # Remove fields from the base model, except the ones in to_keep_values and possibly _id
104
- keys_for_deletion = set(self.base_model._fields.keys()) - set( # noqa
105
- to_keep_values
106
- ) # noqa
104
+ keys_for_deletion = self.base_model._fields.keys() - to_keep_values # noqa
107
105
  if delete_id:
108
106
  keys_for_deletion.add("id")
109
107
  for key in keys_for_deletion:
@@ -128,12 +126,12 @@ class Aggify:
128
126
  return self
129
127
 
130
128
  @last_out_stage_check
131
- def order_by(self, *fields: Union[str, List[str]]) -> "Aggify":
129
+ def order_by(self, *order_fields: Union[str, List[str]]) -> "Aggify":
132
130
  sort_dict = {
133
131
  get_db_field(self.base_model, field.replace("-", "")): -1
134
132
  if field.startswith("-")
135
- else 1 # noqa
136
- for field in fields
133
+ else 1
134
+ for field in order_fields
137
135
  }
138
136
  self.pipelines.append({"$sort": sort_dict})
139
137
  return self
@@ -144,19 +142,18 @@ class Aggify:
144
142
  return self
145
143
 
146
144
  @last_out_stage_check
147
- def add_fields(self, **_fields) -> "Aggify": # noqa
148
- """
149
- Generates a MongoDB addFields pipeline stage.
145
+ def add_fields(self, **fields) -> "Aggify": # noqa
146
+ """Generates a MongoDB addFields pipeline stage.
150
147
 
151
148
  Args:
152
- _fields: A dictionary of field expressions and values.
149
+ fields: A dictionary of field expressions and values.
153
150
 
154
151
  Returns:
155
152
  A MongoDB add_fields pipeline stage.
156
153
  """
157
154
  add_fields_stage = {"$addFields": {}}
158
155
 
159
- for field, expression in _fields.items():
156
+ for field, expression in fields.items():
160
157
  field = field.replace("__", ".")
161
158
  if isinstance(expression, str):
162
159
  add_fields_stage["$addFields"][field] = {"$literal": expression}
@@ -169,7 +166,9 @@ class Aggify:
169
166
  else:
170
167
  raise AggifyValueError([str, F, list], type(expression))
171
168
  # TODO: Should be checked if new field is embedded, create embedded field.
172
- self.base_model._fields[field.replace("$", "")] = fields.IntField() # noqa
169
+ self.base_model._fields[ # noqa
170
+ field.replace("$", "")
171
+ ] = mongoengine_fields.IntField()
173
172
 
174
173
  self.pipelines.append(add_fields_stage)
175
174
  return self
@@ -238,12 +237,8 @@ class Aggify:
238
237
  """
239
238
  Builds the pipelines list based on the query parameters.
240
239
  """
241
- skip_list = []
242
240
 
243
241
  for key, value in query.items():
244
- if key in skip_list:
245
- continue
246
-
247
242
  # Split the key to access the field information.
248
243
  split_query = key.split("__")
249
244
 
@@ -257,8 +252,8 @@ class Aggify:
257
252
  or "document_type_obj"
258
253
  not in join_field.__dict__ # Check whether this field is a join field or not.
259
254
  or issubclass(
260
- join_field.document_type, EmbeddedDocument # noqa
261
- ) # Check whether this field is embedded field or not
255
+ join_field.document_type, EmbeddedDocument # noqa
256
+ ) # Check whether this field is embedded field or not
262
257
  or len(split_query) == 1
263
258
  or (len(split_query) == 2 and split_query[1] in Operators.ALL_OPERATORS)
264
259
  ):
@@ -276,7 +271,6 @@ class Aggify:
276
271
  matches = []
277
272
  for k, v in query.items():
278
273
  if k.split("__")[0] == split_query[0]:
279
- skip_list.append(k)
280
274
  _key = k.replace("__", ".", 1)
281
275
  match = self.__match({_key: v}).get("$match")
282
276
  if match != {}:
@@ -309,7 +303,7 @@ class Aggify:
309
303
  def unwind(
310
304
  self,
311
305
  path: str,
312
- include_index_array: Union[str, None] = None,
306
+ include_array_index: Union[str, None] = None,
313
307
  preserve: bool = False,
314
308
  ) -> "Aggify":
315
309
  """Generates a MongoDB unwind pipeline stage.
@@ -319,7 +313,7 @@ class Aggify:
319
313
  To specify a field path, prefix the field name with a dollar sign $
320
314
  and enclose in quotes.
321
315
 
322
- include_index_array: The name of a new field to hold the array index of the element.
316
+ include_array_index: The name of a new field to hold the array index of the element.
323
317
  The name cannot start with a dollar sign $.
324
318
 
325
319
  preserve: Whether to preserve null and empty arrays.
@@ -344,29 +338,19 @@ class Aggify:
344
338
  docs: https://www.mongodb.com/docs/manual/reference/operator/aggregation/unwind/
345
339
  """
346
340
  path = self.get_field_name_recursively(path)
347
- if include_index_array is None and preserve is False:
348
- self.pipelines.append({"$unwind": f"${path}"})
349
- return self
350
- self.pipelines.append(
351
- {
352
- "$unwind": {
353
- "path": f"${path}",
354
- "includeArrayIndex": include_index_array,
355
- "preserveNullAndEmptyArrays": preserve,
356
- }
357
- }
358
- )
341
+ if include_array_index is None and preserve is False:
342
+ unwind_stage = {"$unwind": f"${path}"}
343
+ else:
344
+ unwind_stage = {"$unwind": {"path": f"${path}"}}
345
+ if preserve:
346
+ unwind_stage["$unwind"]["preserveNullAndEmptyArrays"] = preserve
347
+ if include_array_index:
348
+ unwind_stage["$unwind"][
349
+ "includeArrayIndex"
350
+ ] = include_array_index.replace("$", "")
351
+ self.pipelines.append(unwind_stage)
359
352
  return self
360
353
 
361
- def aggregate(self):
362
- """
363
- Returns the aggregated results.
364
-
365
- Returns:
366
- The aggregated results.
367
- """
368
- return self.base_model.objects.aggregate(*self.pipelines) # type: ignore
369
-
370
354
  def annotate(
371
355
  self, annotate_name: str, accumulator: str, f: Union[Union[str, Dict], F, int]
372
356
  ) -> "Aggify":
@@ -390,31 +374,49 @@ class Aggify:
390
374
  """
391
375
 
392
376
  # Some of the accumulator fields might be false and should be checked.
377
+ # noinspection SpellCheckingInspection
393
378
  aggregation_mapping: Dict[str, Type] = {
394
- "sum": (fields.FloatField(), "$sum"),
395
- "avg": (fields.FloatField(), "$avg"),
396
- "stdDevPop": (fields.FloatField(), "$stdDevPop"),
397
- "stdDevSamp": (fields.FloatField(), "$stdDevSamp"),
398
- "push": (fields.ListField(), "$push"),
399
- "addToSet": (fields.ListField(), "$addToSet"),
400
- "count": (fields.IntField(), "$count"),
401
- "first": (fields.EmbeddedDocumentField(fields.EmbeddedDocument), "$first"),
402
- "last": (fields.EmbeddedDocumentField(fields.EmbeddedDocument), "$last"),
403
- "max": (fields.DynamicField(), "$max"),
404
- "accumulator": (fields.DynamicField(), "$accumulator"),
405
- "min": (fields.DynamicField(), "$min"),
406
- "median": (fields.DynamicField(), "$median"),
407
- "mergeObjects": (fields.DictField(), "$mergeObjects"),
408
- "top": (fields.EmbeddedDocumentField(fields.EmbeddedDocument), "$top"),
379
+ "sum": (mongoengine_fields.FloatField(), "$sum"),
380
+ "avg": (mongoengine_fields.FloatField(), "$avg"),
381
+ "stdDevPop": (mongoengine_fields.FloatField(), "$stdDevPop"),
382
+ "stdDevSamp": (mongoengine_fields.FloatField(), "$stdDevSamp"),
383
+ "push": (mongoengine_fields.ListField(), "$push"),
384
+ "addToSet": (mongoengine_fields.ListField(), "$addToSet"),
385
+ "count": (mongoengine_fields.IntField(), "$count"),
386
+ "first": (
387
+ mongoengine_fields.EmbeddedDocumentField(
388
+ mongoengine_fields.EmbeddedDocument
389
+ ),
390
+ "$first",
391
+ ),
392
+ "last": (
393
+ mongoengine_fields.EmbeddedDocumentField(
394
+ mongoengine_fields.EmbeddedDocument
395
+ ),
396
+ "$last",
397
+ ),
398
+ "max": (mongoengine_fields.DynamicField(), "$max"),
399
+ "accumulator": (mongoengine_fields.DynamicField(), "$accumulator"),
400
+ "min": (mongoengine_fields.DynamicField(), "$min"),
401
+ "median": (mongoengine_fields.DynamicField(), "$median"),
402
+ "mergeObjects": (mongoengine_fields.DictField(), "$mergeObjects"),
403
+ "top": (
404
+ mongoengine_fields.EmbeddedDocumentField(
405
+ mongoengine_fields.EmbeddedDocument
406
+ ),
407
+ "$top",
408
+ ),
409
409
  "bottom": (
410
- fields.EmbeddedDocumentField(fields.EmbeddedDocument),
410
+ mongoengine_fields.EmbeddedDocumentField(
411
+ mongoengine_fields.EmbeddedDocument
412
+ ),
411
413
  "$bottom",
412
414
  ),
413
- "topN": (fields.ListField(), "$topN"),
414
- "bottomN": (fields.ListField(), "$bottomN"),
415
- "firstN": (fields.ListField(), "$firstN"),
416
- "lastN": (fields.ListField(), "$lastN"),
417
- "maxN": (fields.ListField(), "$maxN"),
415
+ "topN": (mongoengine_fields.ListField(), "$topN"),
416
+ "bottomN": (mongoengine_fields.ListField(), "$bottomN"),
417
+ "firstN": (mongoengine_fields.ListField(), "$firstN"),
418
+ "lastN": (mongoengine_fields.ListField(), "$lastN"),
419
+ "maxN": (mongoengine_fields.ListField(), "$maxN"),
418
420
  }
419
421
 
420
422
  try:
@@ -553,18 +555,23 @@ class Aggify:
553
555
  foreign_field: Union[str, None] = None,
554
556
  ) -> "Aggify":
555
557
  """
556
- Generates a MongoDB lookup pipeline stage.
558
+ Generates a MongoDB lookup pipeline stage.
557
559
 
558
- Args:
559
- from_collection (Document): The document representing the collection to perform the lookup on.
560
- as_name (str): The name of the new field to create.
561
- query (list[Q] | Union[Q, None], optional): List of desired queries with Q function or a single query.
562
- let (Union[List[str], None], optional): The local field(s) to join on. If provided, localField and foreignField are not used.
563
- local_field (Union[str, None], optional): The local field to join on when let is not provided.
564
- foreign_field (Union[str, None], optional): The foreign field to join on when let is not provided.
560
+ Args:
561
+ from_collection (Document): The document representing the collection to perform the lookup on.
562
+ as_name (str): The name of the new field to create.
563
+ query (list[Q] | Union[Q, None], optional): List of desired queries with Q function or a single query.
564
+ let (Union[List[str], None], optional): The local field(s) to join on. If provided,
565
+ localField and foreignField are not used.
566
+ local_field (Union[str, None], optional): The local field to join on when `let` is not provided.
567
+ foreign_field (Union[str, None], optional): The foreign field to join on when `let` is not provided.
568
+ let (Union[List[str], None], optional): The local field(s) to join on. If provided,
569
+ localField and foreignField are not used.
570
+ local_field (Union[str, None], optional): The local field to join on when let not provided.
571
+ foreign_field (Union[str, None], optional): The foreign field to join on when let not provided.
565
572
 
566
- Returns:
567
- Aggify: An instance of the Aggify class representing a MongoDB lookup pipeline stage.
573
+ Returns:
574
+ Aggify: An instance of the Aggify class representing a MongoDB lookup pipeline stage.
568
575
  """
569
576
 
570
577
  lookup_stages = []
@@ -576,8 +583,6 @@ class Aggify:
576
583
  expected_list=[["local_field", "foreign_field"], "let"]
577
584
  )
578
585
  elif not let:
579
- if not (local_field and foreign_field):
580
- raise InvalidArgument(expected_list=["local_field", "foreign_field"])
581
586
  lookup_stage = {
582
587
  "$lookup": {
583
588
  "from": from_collection_name,
@@ -632,7 +637,7 @@ class Aggify:
632
637
  return self
633
638
 
634
639
  @staticmethod
635
- def get_model_field(model: CollectionType, field: str) -> fields:
640
+ def get_model_field(model: Type[Document], field: str) -> mongoengine_fields:
636
641
  """
637
642
  Get the field definition of a specified field in a MongoDB model.
638
643
 
@@ -757,15 +762,15 @@ class Aggify:
757
762
  # List of valid redaction values
758
763
  redact_values = ["DESCEND", "PRUNE", "KEEP"]
759
764
 
760
- def clean_then_else(then_value, else_value):
765
+ def clean_then_else(_then_value, _else_value):
761
766
  """
762
767
  Helper function to sanitize then_value and else_value.
763
768
 
764
769
  Strips the dollar sign and converts values to uppercase.
765
770
  """
766
771
  return (
767
- then_value.replace("$", "").upper(),
768
- else_value.replace("$", "").upper(),
772
+ _then_value.replace("$", "").upper(),
773
+ _else_value.replace("$", "").upper(),
769
774
  )
770
775
 
771
776
  # Clean the provided then_value and else_value
@@ -8,15 +8,16 @@ from aggify.utilty import get_db_field
8
8
 
9
9
 
10
10
  class Operators:
11
+ # noinspection SpellCheckingInspection
11
12
  QUERY_OPERATORS = {
12
13
  "exact": "$eq",
13
14
  "iexact": "$regex",
14
15
  "contains": "$regex",
15
- "icontains": "$regex", # noqa
16
+ "icontains": "$regex",
16
17
  "startswith": "$regex",
17
- "istartswith": "$regex", # noqa
18
+ "istartswith": "$regex",
18
19
  "endswith": "$regex",
19
- "iendswith": "$regex", # noqa
20
+ "iendswith": "$regex",
20
21
  "in": "$in",
21
22
  "nin": "$nin",
22
23
  "ne": "$ne",
@@ -35,6 +36,7 @@ class Operators:
35
36
  **COMPARISON_OPERATORS,
36
37
  }
37
38
 
39
+ # noinspection SpellCheckingInspection
38
40
  REGEX_PATTERNS = {
39
41
  "iexact": "^{value}$",
40
42
  "contains": "{value}",
@@ -45,6 +47,7 @@ class Operators:
45
47
  "iendswith": "{value}$",
46
48
  }
47
49
 
50
+ # noinspection SpellCheckingInspection
48
51
  REGEX_OPTIONS = {
49
52
  "iexact": "i",
50
53
  "icontains": "i",
@@ -79,11 +82,6 @@ class Operators:
79
82
  }
80
83
  else:
81
84
  self.match_query[field] = {Operators.ALL_OPERATORS[operator]: value}
82
- else:
83
- # Default behavior
84
- self.match_query[field] = {
85
- Operators.ALL_OPERATORS.get(operator, operator): value
86
- }
87
85
 
88
86
  return self.match_query
89
87
 
@@ -104,7 +102,7 @@ class Q:
104
102
  yield "$match", self.conditions
105
103
 
106
104
  def __or__(self, other):
107
- if self.conditions.get("$or", None):
105
+ if self.conditions.get("$or"):
108
106
  self.conditions["$or"].append(dict(other)["$match"])
109
107
  combined_conditions = self.conditions
110
108
 
@@ -113,7 +111,7 @@ class Q:
113
111
  return Q(**combined_conditions)
114
112
 
115
113
  def __and__(self, other):
116
- if self.conditions.get("$and", None):
114
+ if self.conditions.get("$and"):
117
115
  self.conditions["$and"].append(dict(other)["$match"])
118
116
  combined_conditions = self.conditions
119
117
  else:
@@ -135,7 +133,7 @@ class F:
135
133
  def to_dict(self):
136
134
  return self.field
137
135
 
138
- def __add__(self, other): # TODO: add type for 'other'
136
+ def __add__(self, other):
139
137
  if isinstance(other, F):
140
138
  other = other.field
141
139
 
@@ -245,7 +243,12 @@ class Match:
245
243
 
246
244
  @staticmethod
247
245
  def validate_operator(key: str):
248
- operator = key.rsplit("__", 1)[1]
246
+ _op = key.rsplit("__", 1)
247
+ try:
248
+ operator = _op[1]
249
+ except IndexError:
250
+ raise InvalidOperator(str(_op)) from None
251
+
249
252
  if operator not in Operators.COMPARISON_OPERATORS:
250
253
  raise InvalidOperator(operator)
251
254
 
@@ -272,15 +275,15 @@ class Match:
272
275
  def compile(self, pipelines: list) -> Dict[str, Dict[str, list]]:
273
276
  match_query = {}
274
277
  for key, value in self.matches.items():
278
+ if isinstance(value, F):
279
+ if F.is_suitable_for_match(key) is False:
280
+ raise InvalidOperator(key)
281
+
275
282
  if "__" not in key:
276
283
  key = get_db_field(self.base_model, key)
277
284
  match_query[key] = value
278
285
  continue
279
286
 
280
- if isinstance(value, F):
281
- if F.is_suitable_for_match(key) is False:
282
- raise InvalidOperator(key)
283
-
284
287
  field, operator, *_ = key.split("__")
285
288
  if (
286
289
  self.is_base_model_field(field)
@@ -28,7 +28,9 @@ class AnnotationError(InvalidPipelineStageError):
28
28
 
29
29
  class OutStageError(InvalidPipelineStageError):
30
30
  def __init__(self, stage):
31
- self.message = f"You cannot add a {self!r} pipeline after $out stage!"
31
+ self.message = (
32
+ f"You cannot add a {self!r} pipeline after $out stage! stage : {stage}"
33
+ )
32
34
  super().__init__(self.message)
33
35
 
34
36
 
@@ -1,7 +1,6 @@
1
1
  from typing import Union, Dict, TypeVar, Callable
2
- from mongoengine import Document
3
2
  from bson import ObjectId
3
+ from mongoengine import Document
4
4
 
5
5
  QueryParams = Union[int, None, str, bool, float, Dict, ObjectId]
6
-
7
6
  CollectionType = TypeVar("CollectionType", bound=Callable[..., Document])
@@ -1,20 +1,8 @@
1
1
  from typing import Any, Union, List, Dict
2
2
 
3
3
  from mongoengine import Document
4
-
5
- from aggify.exceptions import MongoIndexError, InvalidField, AlreadyExistsField
6
4
  from aggify.types import CollectionType
7
-
8
-
9
- def int_to_slice(final_index: int) -> slice:
10
- """
11
- Converts an integer to a slice, assuming that the start index is 0.
12
-
13
- Examples:
14
- >>> int_to_slice(3)
15
- slice(0, 2)
16
- """
17
- return slice(0, final_index)
5
+ from aggify.exceptions import MongoIndexError, InvalidField, AlreadyExistsField
18
6
 
19
7
 
20
8
  def to_mongo_positive_index(index: Union[int, slice]) -> slice:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aggify"
3
- version = "0.2.8"
3
+ version = "0.3.0"
4
4
  authors = ["SeYeD.Dev <me@seyed.dev>"]
5
5
  repository = "https://github.com/Aggify/aggify"
6
6
  documentation = "https://github.com/Aggify/aggify/docs"
File without changes
File without changes