polyswarm-api 4.3.0__tar.gz → 4.5.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.
Files changed (20) hide show
  1. {polyswarm_api-4.3.0/src/polyswarm_api.egg-info → polyswarm_api-4.5.0}/PKG-INFO +1 -1
  2. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/pyproject.toml +2 -2
  3. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/__init__.py +1 -1
  4. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/aio/api.py +84 -5
  5. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/api.py +100 -4
  6. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/core.py +12 -1
  7. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/resources.py +59 -0
  8. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0/src/polyswarm_api.egg-info}/PKG-INFO +1 -1
  9. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/LICENSE +0 -0
  10. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/README.md +0 -0
  11. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/setup.cfg +0 -0
  12. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/aio/__init__.py +0 -0
  13. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/aio/session.py +0 -0
  14. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/exceptions.py +0 -0
  15. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/session.py +0 -0
  16. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api/settings.py +0 -0
  17. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api.egg-info/SOURCES.txt +0 -0
  18. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api.egg-info/dependency_links.txt +0 -0
  19. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api.egg-info/requires.txt +0 -0
  20. {polyswarm_api-4.3.0 → polyswarm_api-4.5.0}/src/polyswarm_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyswarm_api
3
- Version: 4.3.0
3
+ Version: 4.5.0
4
4
  Summary: Client library to simplify interacting with the PolySwarm consumer API
5
5
  Author-email: PolySwarm Developers <info@polyswarm.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polyswarm_api"
7
- version = "4.3.0"
7
+ version = "4.5.0"
8
8
  description = "Client library to simplify interacting with the PolySwarm consumer API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<4"
@@ -55,7 +55,7 @@ package-dir = { "" = "src" }
55
55
  where = ["src"]
56
56
 
57
57
  [tool.bumpversion]
58
- current_version = "4.3.0"
58
+ current_version = "4.5.0"
59
59
  commit = true
60
60
  tag = false
61
61
  sign_tags = true
@@ -1,5 +1,5 @@
1
1
  # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names
2
- __version__ = '4.3.0'
2
+ __version__ = '4.5.0'
3
3
  __release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest'
4
4
 
5
5
  from . import api
@@ -18,7 +18,7 @@ import logging
18
18
  import time
19
19
 
20
20
  from polyswarm_api import exceptions, resources, settings
21
- from polyswarm_api.core import PolyswarmRequest
21
+ from polyswarm_api.core import PolyswarmRequest, _as_result_bound
22
22
 
23
23
  from .session import AsyncPolyswarmSession
24
24
 
@@ -502,23 +502,35 @@ class PolySwarmAsyncAPI:
502
502
  return await self._single(resources.LiveYaraRuleset.delete(self, rule_id=rule_id))
503
503
 
504
504
  async def live_feed(self, since=None, rule_name=None, family=None,
505
- polyscore_lower=None, polyscore_upper=None, community=None):
505
+ polyscore_lower=None, polyscore_upper=None, community=None,
506
+ livescan_id=None, max_results=None):
506
507
  """
507
508
  Get live hunts feed
508
509
 
509
- :param since: Fetch results from the last "since" minutes
510
+ :param since: Window in SECONDS (this said "minutes" in earlier releases and was
511
+ wrong). Absent or 0 means no time filter at all.
510
512
  :param rule_name: Filter hunt results on the provided rule name (exact match).
511
513
  :param family: Filter hunt results based on the family name (exact match).
512
514
  :param polyscore_lower: Polyscore lower bound for the hunt results.
513
515
  :param polyscore_upper: Polyscore upper bound for the hunt results.
514
516
  :param community: Community to retrieve live results from, or public/private.
517
+ :param livescan_id: Scope the feed to one live hunt's results.
518
+ :param max_results: Total results to yield, not a page size — paging
519
+ continues in the server's own chunks until the total is reached.
520
+ None, 0 or a negative means no bound: every page, as before.
515
521
  :return: Generator of HuntResult resources
516
522
  """
523
+ bound = _as_result_bound(max_results)
524
+ yielded = 0
517
525
  async for item in self._paginate(resources.LiveHuntResult.list(
518
526
  self, since=since, rule_name=rule_name, family=family,
519
527
  polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper,
528
+ livescan_id=livescan_id,
520
529
  community=community or self.community)):
521
530
  yield item
531
+ yielded += 1
532
+ if bound is not None and yielded >= bound:
533
+ return
522
534
 
523
535
  async def live_feed_delete(self, result_ids):
524
536
  """
@@ -684,15 +696,82 @@ class PolySwarmAsyncAPI:
684
696
  logger.info('Delete ruleset %s', ruleset_id)
685
697
  return await self._single(resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community))
686
698
 
687
- async def ruleset_list(self):
699
+ async def ruleset_list(self, name=None, status=None, favorites_only=None,
700
+ has_new_results=None, sort=None,
701
+ exclude_favorites=None):
688
702
  """
689
703
  List all YaraRulesets for the current account.
704
+
705
+ All filters are optional and conjunctive:
706
+ :param name: Case-insensitive substring match on the ruleset name.
707
+ :param status: 'active' returns only rulesets whose live hunt is
708
+ currently running.
709
+ :param favorites_only: True returns only favorited rulesets.
710
+ :param exclude_favorites: True returns only the rulesets that are NOT
711
+ favorited — the inverse of ``favorites_only``, and refused together
712
+ with it (a contradiction, answered with an error rather than an
713
+ empty list). It exists for clients that render the favorites as
714
+ their own list: the favorites are a separate, unpaginated fetch
715
+ bounded by the account's budget, so leaving them in the paginated
716
+ list too makes a page either repeat a row or come back short.
717
+ Appended to the signature rather than placed beside
718
+ ``favorites_only`` so a positional caller keeps working.
719
+ :param has_new_results: True returns only rulesets whose stored
720
+ new-results counter is positive. The counter (and its window) is
721
+ maintained server-side by a scheduled refresh; rows carry it as
722
+ ``new_results_count`` with ``new_results_counted_at`` marking when
723
+ it was last refreshed. There is no per-request window parameter.
724
+ :param sort: ``'active_first'`` returns the rulesets that carry a live
725
+ hunt link first, newest first within each block. Default (None) is
726
+ newest first. "Newest first" is the server's own insertion key, NOT
727
+ the ``id`` on the rows you get back — that one is unique but
728
+ unordered, so dedupe with it and never resume or bound a walk with
729
+ it. Applied SERVER-side, across pages — the list is
730
+ keyset-paginated, so a client-side sort would only ever reorder one
731
+ page; the SDK never re-orders rows. Reuse a page's ``offset`` only
732
+ with the same ``sort``: the server refuses a cursor minted under
733
+ the other order.
734
+
735
+ Two server-side properties of that key, neither of them SDK
736
+ behaviour. It ranks on the stored link, which is a WIDER predicate
737
+ than the one ``livescan_id`` is rendered under: a legacy row whose
738
+ hunt was stopped without clearing the link ranks in the leading
739
+ block while still serializing ``livescan_id`` as ``None``. Read the
740
+ field to decide whether a ruleset is running; never the position.
741
+
742
+ And the key is MUTABLE, unlike that default: a ruleset whose
743
+ live hunt stops mid-walk falls back into the idle block below the
744
+ cursor and is yielded twice, and one started mid-walk moves above
745
+ the cursor and is skipped for the rest of that walk. That is a
746
+ property of the walk, so starting fresh from the first page does
747
+ not avoid it. This generator streams pages and does not dedupe —
748
+ dedupe by ``id`` if you consume more than one page.
690
749
  :return: A generator of YaraRuleset resources
691
750
  """
692
751
  logger.info('List rulesets')
693
- async for item in self._paginate(resources.YaraRuleset.list(self, community=self.community)):
752
+ async for item in self._paginate(resources.YaraRuleset.list(
753
+ self, name=name, status=status, favorites_only=favorites_only,
754
+ has_new_results=has_new_results, sort=sort,
755
+ exclude_favorites=exclude_favorites,
756
+ community=self.community)):
694
757
  yield item
695
758
 
759
+ async def ruleset_favorite(self, ruleset_id, favorite=True):
760
+ """
761
+ Favorite or unfavorite a YaraRuleset. Idempotent; works while a live
762
+ hunt is running. Favorites are shared by the whole team and capped
763
+ (the response carries favorites_used / favorites_limit); when the
764
+ budget is exhausted the server refuses with a machine-readable
765
+ FAVORITE_LIMIT error.
766
+
767
+ :param ruleset_id: Id of the ruleset
768
+ :param favorite: True to star, False to unstar
769
+ :return: A YaraRulesetFavorite resource
770
+ """
771
+ logger.info('%s ruleset %s', 'Favorite' if favorite else 'Unfavorite', ruleset_id)
772
+ return await self._single(resources.YaraRulesetFavorite.update(
773
+ self, id=ruleset_id, favorite=favorite, community=self.community))
774
+
696
775
  async def tag_link_get(self, sha256):
697
776
  """
698
777
  Fetch the Tags and Families associated with the given sha256.
@@ -20,7 +20,7 @@ import io
20
20
  import logging
21
21
 
22
22
  from polyswarm_api import exceptions, resources, settings
23
- from polyswarm_api.core import PolyswarmRequest
23
+ from polyswarm_api.core import PolyswarmRequest, _as_result_bound
24
24
 
25
25
  from .session import PolyswarmSession
26
26
 
@@ -568,18 +568,27 @@ class PolyswarmAPI:
568
568
  polyscore_lower=None,
569
569
  polyscore_upper=None,
570
570
  community=None,
571
+ livescan_id=None,
572
+ max_results=None,
571
573
  ):
572
574
  """
573
575
  Get live hunts feed
574
576
 
575
- :param since: Fetch results from the last "since" minutes
577
+ :param since: Window in SECONDS (this said "minutes" in earlier releases and was
578
+ wrong). Absent or 0 means no time filter at all.
576
579
  :param rule_name: Filter hunt results on the provided rule name (exact match).
577
580
  :param family: Filter hunt results based on the family name (exact match).
578
581
  :param polyscore_lower: Polyscore lower bound for the hunt results.
579
582
  :param polyscore_upper: Polyscore upper bound for the hunt results.
580
583
  :param community: Community to retrieve live results from, or public/private.
584
+ :param livescan_id: Scope the feed to one live hunt's results.
585
+ :param max_results: Total results to yield, not a page size — paging
586
+ continues in the server's own chunks until the total is reached.
587
+ None, 0 or a negative means no bound: every page, as before.
581
588
  :return: Generator of HuntResult resources
582
589
  """
590
+ bound = _as_result_bound(max_results)
591
+ yielded = 0
583
592
  for item in self._paginate(
584
593
  resources.LiveHuntResult.list(
585
594
  self,
@@ -588,10 +597,14 @@ class PolyswarmAPI:
588
597
  family=family,
589
598
  polyscore_lower=polyscore_lower,
590
599
  polyscore_upper=polyscore_upper,
600
+ livescan_id=livescan_id,
591
601
  community=community or self.community,
592
602
  )
593
603
  ):
594
604
  yield item
605
+ yielded += 1
606
+ if bound is not None and yielded >= bound:
607
+ return
595
608
 
596
609
  def live_feed_delete(self, result_ids):
597
610
  """
@@ -823,17 +836,100 @@ class PolyswarmAPI:
823
836
  resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community)
824
837
  )
825
838
 
826
- def ruleset_list(self):
839
+ def ruleset_list(
840
+ self,
841
+ name=None,
842
+ status=None,
843
+ favorites_only=None,
844
+ has_new_results=None,
845
+ sort=None,
846
+ exclude_favorites=None,
847
+ ):
827
848
  """
828
849
  List all YaraRulesets for the current account.
850
+
851
+ All filters are optional and conjunctive:
852
+ :param name: Case-insensitive substring match on the ruleset name.
853
+ :param status: 'active' returns only rulesets whose live hunt is
854
+ currently running.
855
+ :param favorites_only: True returns only favorited rulesets.
856
+ :param exclude_favorites: True returns only the rulesets that are NOT
857
+ favorited — the inverse of ``favorites_only``, and refused together
858
+ with it (a contradiction, answered with an error rather than an
859
+ empty list). It exists for clients that render the favorites as
860
+ their own list: the favorites are a separate, unpaginated fetch
861
+ bounded by the account's budget, so leaving them in the paginated
862
+ list too makes a page either repeat a row or come back short.
863
+ Appended to the signature rather than placed beside
864
+ ``favorites_only`` so a positional caller keeps working.
865
+ :param has_new_results: True returns only rulesets whose stored
866
+ new-results counter is positive. The counter (and its window) is
867
+ maintained server-side by a scheduled refresh; rows carry it as
868
+ ``new_results_count`` with ``new_results_counted_at`` marking when
869
+ it was last refreshed. There is no per-request window parameter.
870
+ :param sort: ``'active_first'`` returns the rulesets that carry a live
871
+ hunt link first, newest first within each block. Default (None) is
872
+ newest first. "Newest first" is the server's own insertion key, NOT
873
+ the ``id`` on the rows you get back — that one is unique but
874
+ unordered, so dedupe with it and never resume or bound a walk with
875
+ it. Applied SERVER-side, across pages — the list is
876
+ keyset-paginated, so a client-side sort would only ever reorder one
877
+ page; the SDK never re-orders rows. Reuse a page's ``offset`` only
878
+ with the same ``sort``: the server refuses a cursor minted under
879
+ the other order.
880
+
881
+ Two server-side properties of that key, neither of them SDK
882
+ behaviour. It ranks on the stored link, which is a WIDER predicate
883
+ than the one ``livescan_id`` is rendered under: a legacy row whose
884
+ hunt was stopped without clearing the link ranks in the leading
885
+ block while still serializing ``livescan_id`` as ``None``. Read the
886
+ field to decide whether a ruleset is running; never the position.
887
+
888
+ And the key is MUTABLE, unlike that default: a ruleset whose
889
+ live hunt stops mid-walk falls back into the idle block below the
890
+ cursor and is yielded twice, and one started mid-walk moves above
891
+ the cursor and is skipped for the rest of that walk. That is a
892
+ property of the walk, so starting fresh from the first page does
893
+ not avoid it. This generator streams pages and does not dedupe —
894
+ dedupe by ``id`` if you consume more than one page.
829
895
  :return: A generator of YaraRuleset resources
830
896
  """
831
897
  logger.info("List rulesets")
832
898
  for item in self._paginate(
833
- resources.YaraRuleset.list(self, community=self.community)
899
+ resources.YaraRuleset.list(
900
+ self,
901
+ name=name,
902
+ status=status,
903
+ favorites_only=favorites_only,
904
+ has_new_results=has_new_results,
905
+ sort=sort,
906
+ exclude_favorites=exclude_favorites,
907
+ community=self.community,
908
+ )
834
909
  ):
835
910
  yield item
836
911
 
912
+ def ruleset_favorite(self, ruleset_id, favorite=True):
913
+ """
914
+ Favorite or unfavorite a YaraRuleset. Idempotent; works while a live
915
+ hunt is running. Favorites are shared by the whole team and capped
916
+ (the response carries favorites_used / favorites_limit); when the
917
+ budget is exhausted the server refuses with a machine-readable
918
+ FAVORITE_LIMIT error.
919
+
920
+ :param ruleset_id: Id of the ruleset
921
+ :param favorite: True to star, False to unstar
922
+ :return: A YaraRulesetFavorite resource
923
+ """
924
+ logger.info(
925
+ "%s ruleset %s", "Favorite" if favorite else "Unfavorite", ruleset_id
926
+ )
927
+ return self._single(
928
+ resources.YaraRulesetFavorite.update(
929
+ self, id=ruleset_id, favorite=favorite, community=self.community
930
+ )
931
+ )
932
+
837
933
  def tag_link_get(self, sha256):
838
934
  """
839
935
  Fetch the Tags and Families associated with the given sha256.
@@ -16,7 +16,8 @@ parsing logic have a single source of truth:
16
16
  builders (``create``, ``get``, ``head``, ``update``, ``delete``,
17
17
  ``list``) construct ``PolyswarmRequest`` descriptors.
18
18
  - ``Hashable`` + ``is_valid_*`` validators, ``parse_isoformat``,
19
- ``_normalise_bool_params``, ``RequestParamsEncoder``.
19
+ ``_normalise_bool_params``, ``RequestParamsEncoder``,
20
+ ``_as_result_bound``.
20
21
 
21
22
  Both transports import from here.
22
23
  """
@@ -668,6 +669,16 @@ class BaseJsonResource(BaseResource):
668
669
  )
669
670
 
670
671
 
672
+ def _as_result_bound(max_results):
673
+ """The caller's result bound, or None when there isn't one.
674
+
675
+ None, 0 and negatives all mean "no bound".
676
+ """
677
+ if not max_results or max_results < 0:
678
+ return None
679
+ return max_results
680
+
681
+
671
682
  # ── Hash helpers ───────────────────────────────────────────────────
672
683
 
673
684
 
@@ -746,12 +746,51 @@ class YaraRuleset(core.BaseJsonResource):
746
746
  self.modified = core.parse_isoformat(content.get('modified'))
747
747
  self.deleted = content.get('deleted')
748
748
  self.yara = content.get('yara')
749
+ # Hunt-page tracking fields. All additive — an older server simply
750
+ # leaves them None.
751
+ self.favorite = content.get('favorite')
752
+ self.favorited_at = core.parse_isoformat(content.get('favorited_at'))
753
+ # Number of rules in the ruleset body. None means "no answer" (the
754
+ # server could not or has not counted), which is different from 0.
755
+ self.rule_count = content.get('rule_count')
756
+ self.historical_hunt_count = content.get('historical_hunt_count')
757
+ # Live results collected in the product window — a STORED counter the
758
+ # server refreshes on a schedule, always present on list rows with a
759
+ # running live hunt. None means "not yet refreshed / no live hunt",
760
+ # which is different from 0 ("refreshed, nothing new").
761
+ self.new_results_count = content.get('new_results_count')
762
+ # When the server last refreshed the counter — the staleness marker
763
+ # that makes a stored count trustworthy (None iff the count is None).
764
+ self.new_results_counted_at = core.parse_isoformat(
765
+ content.get('new_results_counted_at'))
749
766
 
750
767
 
751
768
  class LiveYaraRuleset(YaraRuleset):
752
769
  RESOURCE_ENDPOINT = '/hunt/rule/live'
753
770
 
754
771
 
772
+ class YaraRulesetFavorite(core.BaseJsonResource):
773
+ """The favorite-toggle response: the ruleset's new star state plus the
774
+ team's budget usage, so callers can render "N of M used" without counting
775
+ client-side."""
776
+ RESOURCE_ENDPOINT = '/hunt/rule/favorite'
777
+ # The toggle's payload ({id, favorite}) rides the JSON body — the server
778
+ # reads BOTH from the body — so the default RESOURCE_ID_KEYS (['id'])
779
+ # would move `id` into the query string and the server would 400 with
780
+ # "A valid rule id must be provided". `community` stays a query-string
781
+ # key to match where every ruleset GET/list sends it (the server accepts
782
+ # it from either the query or the body, but never from both at once).
783
+ RESOURCE_ID_KEYS = ['community']
784
+
785
+ def __init__(self, content, api=None):
786
+ super().__init__(content, api=api)
787
+ self.id = content.get('id')
788
+ self.favorite = content.get('favorite')
789
+ self.favorited_at = core.parse_isoformat(content.get('favorited_at'))
790
+ self.favorites_used = content.get('favorites_used')
791
+ self.favorites_limit = content.get('favorites_limit')
792
+
793
+
755
794
  class LiveHuntResult(core.BaseJsonResource):
756
795
  RESOURCE_ENDPOINT = '/hunt/live'
757
796
 
@@ -766,6 +805,11 @@ class LiveHuntResult(core.BaseJsonResource):
766
805
  self.sha1 = content.get('sha1')
767
806
  self.rule_name = content['rule_name']
768
807
  self.tags = content['tags']
808
+ # `.get()`, not a subscript -- both keys are additive, so an older server omits
809
+ # them. None is AMBIGUOUS on both (four causes) and is never a claim that the
810
+ # evidence is complete. Contract: specs/05-downstream-contract.md.
811
+ self.matched_strings = content.get('matched_strings')
812
+ self.matched_strings_dropped = content.get('matched_strings_dropped')
769
813
  self.polyscore = content['polyscore']
770
814
  self.malware_family = content['malware_family']
771
815
  self.detections = content['detections']
@@ -794,6 +838,16 @@ class HistoricalHunt(core.BaseJsonResource):
794
838
  self.progress = content['progress']
795
839
  self.results_csv_uri = content['results_csv_uri']
796
840
  self.communities = content.get('communities')
841
+ # Source-rule provenance. rule_id is the ruleset this hunt was
842
+ # triggered from (None for raw-yara hunts and hunts predating the
843
+ # tracking); rule_modified is a freeze-time audit timestamp.
844
+ self.rule_id = content.get('rule_id')
845
+ self.rule_modified = core.parse_isoformat(content.get('rule_modified'))
846
+ # Tri-state: has the source ruleset's body changed SINCE THE HUNT
847
+ # FROZE IT? True/False when the server could compare; None means
848
+ # unknown (no source rule, or nothing to compare against) — not
849
+ # "unchanged".
850
+ self.source_rule_changed = content.get('source_rule_changed')
797
851
 
798
852
 
799
853
  class HistoricalHuntList(HistoricalHunt):
@@ -814,6 +868,11 @@ class HistoricalHuntResult(core.BaseJsonResource):
814
868
  self.created = core.parse_isoformat(content['created'])
815
869
  self.rule_name = content['rule_name']
816
870
  self.tags = content['tags']
871
+ # `.get()`, not a subscript -- both keys are additive, so an older server omits
872
+ # them. None is AMBIGUOUS on both (four causes) and is never a claim that the
873
+ # evidence is complete. Contract: specs/05-downstream-contract.md.
874
+ self.matched_strings = content.get('matched_strings')
875
+ self.matched_strings_dropped = content.get('matched_strings_dropped')
817
876
  self.polyscore = content['polyscore']
818
877
  self.malware_family = content['malware_family']
819
878
  self.detections = content['detections']
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyswarm_api
3
- Version: 4.3.0
3
+ Version: 4.5.0
4
4
  Summary: Client library to simplify interacting with the PolySwarm consumer API
5
5
  Author-email: PolySwarm Developers <info@polyswarm.io>
6
6
  License: MIT
File without changes
File without changes
File without changes