diff --git a/swh/graphql/backends/archive.py b/swh/graphql/backends/archive.py index c9345f0..2fe04cc 100644 --- a/swh/graphql/backends/archive.py +++ b/swh/graphql/backends/archive.py @@ -1,134 +1,136 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import os from typing import Any, Dict, Iterable, List, Optional from swh.graphql import server from swh.model.model import ( Content, DirectoryEntry, Origin, OriginVisit, OriginVisitStatus, Release, Revision, Sha1, Sha1Git, ) from swh.model.swhids import ObjectType from swh.storage.interface import PagedResult, PartialBranches, StorageInterface class Archive: def __init__(self) -> None: self.storage: StorageInterface = server.get_storage() def get_origin(self, url: str) -> Optional[Origin]: return list(self.storage.origin_get(origins=[url]))[0] def get_origins( self, after: Optional[str] = None, first: int = 50 ) -> PagedResult[Origin]: return self.storage.origin_list(page_token=after, limit=first) def get_origin_visits( self, origin_url: str, after: Optional[str] = None, first: int = 50 ) -> PagedResult[OriginVisit]: return self.storage.origin_visit_get( origin=origin_url, page_token=after, limit=first ) def get_origin_visit(self, origin_url: str, visit_id: int) -> Optional[OriginVisit]: return self.storage.origin_visit_get_by(origin=origin_url, visit=visit_id) def get_origin_latest_visit(self, origin_url: str) -> Optional[OriginVisit]: return self.storage.origin_visit_get_latest(origin=origin_url) def get_visit_status( self, origin_url: str, visit_id: int, after: Optional[str] = None, first: int = 50, ) -> PagedResult[OriginVisitStatus]: return self.storage.origin_visit_status_get( origin=origin_url, visit=visit_id, page_token=after, limit=first ) def get_latest_visit_status( self, origin_url: str, visit_id: int, allowed_statuses: Optional[List[str]] = None, require_snapshot: bool = False, ) -> Optional[OriginVisitStatus]: return self.storage.origin_visit_status_get_latest( origin_url=origin_url, visit=visit_id, allowed_statuses=allowed_statuses, require_snapshot=require_snapshot, ) def get_origin_snapshots(self, origin_url: str) -> List[Sha1Git]: return self.storage.origin_snapshot_get_all(origin_url=origin_url) def get_snapshot_branches( self, snapshot: Sha1Git, after: bytes = b"", first: int = 50, target_types: Optional[List[str]] = None, name_include: Optional[bytes] = None, + name_exclude_prefix: Optional[bytes] = None, ) -> Optional[PartialBranches]: return self.storage.snapshot_get_branches( snapshot_id=snapshot, branches_from=after, branches_count=first, target_types=target_types, branch_name_include_substring=name_include, + branch_name_exclude_prefix=name_exclude_prefix, ) def get_revisions(self, revision_ids: List[Sha1Git]) -> List[Optional[Revision]]: return self.storage.revision_get(revision_ids=revision_ids) def get_revision_log( self, revision_ids: List[Sha1Git], first: int = 50 ) -> Iterable[Optional[Dict[str, Any]]]: return self.storage.revision_log(revisions=revision_ids, limit=first) def get_releases(self, release_ids: List[Sha1Git]) -> List[Optional[Release]]: return self.storage.release_get(releases=release_ids) def get_directory_entry_by_path( self, directory_id: Sha1Git, path: str ) -> Optional[Dict[str, Any]]: paths = [x.encode() for x in path.strip(os.path.sep).split(os.path.sep)] return self.storage.directory_entry_get_by_path( directory=directory_id, paths=paths ) def get_directory_entries( self, directory_id: Sha1Git, after: Optional[bytes] = None, first: int = 50 ) -> Optional[PagedResult[DirectoryEntry]]: return self.storage.directory_get_entries( directory_id=directory_id, limit=first, page_token=after ) def is_object_available(self, object_id: bytes, object_type: ObjectType) -> bool: mapping = { ObjectType.CONTENT: self.storage.content_missing_per_sha1_git, ObjectType.DIRECTORY: self.storage.directory_missing, ObjectType.RELEASE: self.storage.release_missing, ObjectType.REVISION: self.storage.revision_missing, ObjectType.SNAPSHOT: self.storage.snapshot_missing, } return not list(mapping[object_type]([object_id])) def get_contents(self, checksums: Dict[str, Any]) -> List[Content]: return self.storage.content_find(content=checksums) def get_content_data(self, content_sha1: Sha1) -> Optional[bytes]: return self.storage.content_get_data(content=content_sha1) diff --git a/swh/graphql/resolvers/snapshot_branch.py b/swh/graphql/resolvers/snapshot_branch.py index 8ea0c45..4cd497e 100644 --- a/swh/graphql/resolvers/snapshot_branch.py +++ b/swh/graphql/resolvers/snapshot_branch.py @@ -1,124 +1,129 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from collections import namedtuple from swh.graphql.errors import ObjectNotFoundError from swh.graphql.utils import utils from swh.storage.interface import PagedResult from .base_connection import BaseConnection from .base_node import BaseNode class BaseSnapshotBranchNode(BaseNode): # target field for this node is a UNION type # It is resolved in the top level (resolvers.resolvers.py) def _get_node_from_data(self, node_data: tuple): # node_data is a tuple as returned by _get_paged_result in # SnapshotBranchConnection and _get_node_data in AliasSnapshotBranchNode # overriding to support this special data structure branch_name, branch_obj = node_data node = { "name": branch_name, "type": branch_obj.target_type.value, "target_hash": branch_obj.target, } return namedtuple("NodeObj", node.keys())(*node.values()) def is_type_of(self): return "Branch" @property def targetType(self): # To support the schema naming convention return self._node.type def snapshot_swhid(self): """ Logic to handle multiple branch alias redirects Alias redirects can be any level deep. Hence the parent snapshot can be reached only by a loop This code expects every BranchNode to have a Snapshot parent in the GraphQL query at some level. """ from .snapshot import BaseSnapshotNode parent = self.obj while parent: if isinstance( parent, BaseSnapshotNode ): # Reached the nearest SnapshotNode object return parent.swhid parent = parent.obj # Reached the root query node. This will never happen with the current entrypoints raise ObjectNotFoundError("There is no snapshot associated with the branch") class AliasSnapshotBranchNode(BaseSnapshotBranchNode): obj: BaseSnapshotBranchNode def _get_node_data(self): snapshot_swhid = self.snapshot_swhid() target_branch = self.obj.target_hash alias_branch = self.archive.get_snapshot_branches( snapshot_swhid.object_id, first=1, name_include=target_branch ) if target_branch not in alias_branch["branches"]: raise ObjectNotFoundError( f"Branch name with {target_branch.decode()} is not available" ) # this will be serialized in _get_node_from_data method in the base class return (target_branch, alias_branch["branches"][target_branch]) class SnapshotBranchConnection(BaseConnection): """ Connection resolver for the branches in a snapshot """ from .snapshot import BaseSnapshotNode obj: BaseSnapshotNode _node_class = BaseSnapshotBranchNode def _get_paged_result(self) -> PagedResult: result = self.archive.get_snapshot_branches( self.obj.swhid.object_id, after=self._get_after_arg(), first=self._get_first_arg(), target_types=self.kwargs.get("types"), name_include=self._get_name_include_arg(), + name_exclude_prefix=self._get_name_exclude_prefix_arg(), ) # endCursor is the last branch name, logic for that end_cusrsor = ( result["next_branch"] if result["next_branch"] is not None else None ) # FIXME, this pagination is not consistent with other connections # FIX in swh-storage to return PagedResult # STORAGE-TODO # this will be serialized in _get_node_from_data method in the node class return PagedResult( results=result["branches"].items(), next_page_token=end_cusrsor ) def _get_after_arg(self): # after argument must be an empty string by default after = super()._get_after_arg() return after.encode() if after else b"" def _get_name_include_arg(self): name_include = self.kwargs.get("nameInclude", None) return name_include.encode() if name_include else None + def _get_name_exclude_prefix_arg(self): + name_exclude_prefix = self.kwargs.get("nameExcludePrefix", None) + return name_exclude_prefix.encode() if name_exclude_prefix else None + def _get_index_cursor(self, index: int, node: BaseSnapshotBranchNode): # Snapshot branch is using a different cursor, hence the override return utils.get_encoded_cursor(node.name) diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index d1c21c9..de3f501 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1105 +1,1110 @@ """ SoftWare Heritage persistent Identifier """ scalar SWHID """ ISO-8601 encoded date string """ scalar DateTime """ Content identifier in the form hash-type:hash-value """ scalar ContentHash """ Object with an id """ interface Node { """ Id of the object. This is for caching purpose and should not be used outside the GraphQL API """ id: ID! } """ SWH merkle node object with a SWHID """ interface MerkleNode { """ SWHID of the object """ swhid: SWHID! } """ Information about pagination """ type PageInfo { """ Cursor to request the next page in the connection """ endCursor: String """ Are there more pages in the connection? """ hasNextPage: Boolean! } """ Binary strings; different encodings """ type BinaryString { """ Utf-8 encoded value, any non Unicode character will be replaced """ text: String """ base64 encoded value """ base64: String } """ Connection to origins """ type OriginConnection { """ List of origin edges """ edges: [OriginEdge] """ List of origin objects """ nodes: [Origin] """ Information for pagination """ pageInfo: PageInfo! """ Total number of origin objects in the connection """ totalCount: Int } """ Edge in origin connection """ type OriginEdge { """ Cursor to request the next page after the item """ cursor: String! """ Origin object """ node: Origin } """ A software origin object """ type Origin implements Node { """ Unique identifier """ id: ID! """ Origin URL """ url: String! """ Connection to all the visit objects for the origin """ visits( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): VisitConnection! """ Latest visit object for the origin """ latestVisit: Visit """ Connection to all the snapshots for the origin """ snapshots( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): SnapshotConnection } """ Connection to origin visits """ type VisitConnection { """ List of visit edges """ edges: [VisitEdge] """ List of visit objects """ nodes: [Visit] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit objects in the connection """ totalCount: Int } """ Edge in origin visit connection """ type VisitEdge { """ Cursor to request the next page after the item """ cursor: String! """ Visit object """ node: Visit } """ Possible visit status states """ enum VisitStatusState { created ongoing partial full not_found failed } """ An origin visit object """ type Visit implements Node { """ Unique identifier """ id: ID! """ Visit number for the origin """ visitId: Int """ Visit date ISO-8601 encoded """ date: DateTime! """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String """ Connection to all the status objects for the visit """ statuses( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): VisitStatusConnection """ Latest status object for the Visit """ latestStatus( """ Filter by status state """ allowedStatuses: [VisitStatusState] """ Filter by the availability of a snapshot in the status """ requireSnapshot: Boolean ): VisitStatus } """ Connection to visit status """ type VisitStatusConnection { """ List of visit status edges """ edges: [VisitStatusEdge] """ List of visit status objects """ nodes: [VisitStatus] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit status objects in the connection """ totalCount: Int } """ Edge in visit status connection """ type VisitStatusEdge { """ Cursor to request the next page after the item """ cursor: String! """ Visit status object """ node: VisitStatus } """ A visit status object """ type VisitStatus { """ Status string of the visit (either full, partial or ongoing) """ status: VisitStatusState! """ ISO-8601 encoded date string """ date: DateTime! """ Snapshot object """ snapshot: Snapshot """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String } """ Connection to snapshots """ type SnapshotConnection { """ List of snapshot edges """ edges: [SnapshotEdge] """ List of snapshot objects """ nodes: [Snapshot] """ Information for pagination """ pageInfo: PageInfo! """ Total number of snapshot objects in the connection """ totalCount: Int } """ Edge in snapshot connection """ type SnapshotEdge { """ Cursor to request the next page after the item """ cursor: String! """ Snapshot object """ node: Snapshot } """ A snapshot object """ type Snapshot implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the snapshot object """ swhid: SWHID! """ Connection to all the snapshot branches """ branches( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String """ Filter by branch target types """ types: [BranchTargetType] """ - Filter by branch name + Return branches whose name contains the given substring """ nameInclude: String + + """ + Do not return branches whose name contains the given prefix + """ + nameExcludePrefix: String ): BranchConnection } """ Connection to snapshot branches """ type BranchConnection { """ List of branch edges """ edges: [BranchConnectionEdge] """ List of branch objects """ nodes: [Branch] """ Information for pagination """ pageInfo: PageInfo! """ Total number of branch objects in the connection """ totalCount: Int } """ Edge in snapshot branch connection """ type BranchConnectionEdge { """ Cursor to request the next page after the item """ cursor: String! """ Branch object """ node: Branch } """ A user object """ type Person { """ User's email address """ email: BinaryString """ User's name """ name: BinaryString """ User's full name """ fullname: BinaryString } """ Possible branch target objects """ union BranchTarget = Revision | Release | Branch | Content | Directory | Snapshot """ Possible Branch target types """ enum BranchTargetType { revision release alias content directory snapshot } """ A snapshot branch object """ type Branch { """ Branch name """ name: BinaryString """ Type of Branch target """ targetType: BranchTargetType """ Branch target object """ target: BranchTarget } """ Connection to revisions """ type RevisionConnection { """ List of revision edges """ edges: [RevisionEdge] """ List of revision objects """ nodes: [Revision] """ Information for pagination """ pageInfo: PageInfo! """ Total number of revision objects in the connection """ totalCount: Int } """ Edge in revision connection """ type RevisionEdge { """ Cursor to request the next page after the item """ cursor: String! """ Revision object """ node: Revision } """ A revision object """ type Revision implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the revision object """ swhid: SWHID! """ Message associated to the revision """ message: BinaryString """ """ author: Person """ """ committer: Person """ Revision date ISO-8601 encoded """ date: DateTime """ Type of the revision, eg: git/hg """ type: String """ The unique directory object that revision points to """ directory: Directory """ Connection to all the parents of the revision """ parents( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): RevisionConnection """ Connection to all the revisions heading to this one aka the commit log """ revisionLog( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): RevisionConnection } """ Possible release target objects """ union ReleaseTarget = Release | Revision | Directory | Content """ Possible release target types """ enum ReleaseTargetType { release revision content directory } """ A release object """ type Release implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the release object """ swhid: SWHID! """ The name of the release """ name: BinaryString """ The message associated to the release """ message: BinaryString """ """ author: Person """ Release date ISO-8601 encoded """ date: DateTime """ Type of release target """ targetType: ReleaseTargetType """ Release target object """ target: ReleaseTarget } """ Connection to directory entries """ type DirectoryEntryConnection { """ List of directory entry edges """ edges: [DirectoryEntryEdge] """ List of directory entry objects """ nodes: [DirectoryEntry] """ Information for pagination """ pageInfo: PageInfo! """ Total number of directory entry objects in the connection """ totalCount: Int } """ Edge in directory entry connection """ type DirectoryEntryEdge { """ Cursor to request the next page after the item """ cursor: String! """ Directory entry object """ node: DirectoryEntry } """ Possible directory entry target objects """ union DirectoryEntryTarget = Directory | Content | Revision """ Possible directory entry types """ enum DirectoryEntryTargetType { directory content revision } """ A directory entry object """ type DirectoryEntry { """ The directory entry name """ name: BinaryString """ Directory entry object type; can be file, dir or rev """ targetType: DirectoryEntryTargetType """ Directory entry target object """ target: DirectoryEntryTarget } """ A directory object """ type Directory implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the directory object """ swhid: SWHID! """ Connection to the directory entries """ entries( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String """ Filter by entry name """ nameInclude: String ): DirectoryEntryConnection } """ An object with different content checksums """ type ContentChecksum { blake2s256: String sha1: String sha1_git: String sha256: String } """ Object with different content data representations """ type ContentData { """ URL to download the file data """ url: String } type ContentFileType { """ Detected content encoding """ encoding: String """ Detected MIME type of the content """ mimetype: String } type ContentLanguage { """ Detected programming language if any """ lang: String } type ContentLicense { """ Array of strings containing the detected license names """ licenses: [String] } """ A content object """ type Content implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the content object """ swhid: SWHID! """ Checksums for the content """ checksum: ContentChecksum """ Length of the content in bytes """ length: Int """ Content status, visible or hidden """ status: String """ File content """ data: ContentData """ Information about the content MIME type """ fileType: ContentFileType """ Information about the programming language used in the content """ language: ContentLanguage """ Information about the license of the content """ license: ContentLicense } """ Connection to SearchResults """ type SearchResultConnection { """ List of SearchResult edges """ edges: [SearchResultEdge] """ List of SearchResult objects """ nodes: [SearchResult] """ Information for pagination """ pageInfo: PageInfo! """ Total number of result objects in the connection """ totalCount: Int } """ Edge in SearchResult connection """ type SearchResultEdge { """ Cursor to request the next page after the item """ cursor: String! """ SearchResult object """ node: SearchResult } union SearchResultTarget = Origin | Revision | Release | Content | Directory | Snapshot enum SearchResultTargetType { origin revision release content directory snapshot } """ A SearchResult object """ type SearchResult { """ Result target type """ targetType: SearchResultTargetType """ Result target object """ target: SearchResultTarget } """ The query root of the GraphQL interface. """ type Query { """ Get an origin with its url """ origin( """ URL of the Origin """ url: String! ): Origin """ Get a Connection to all the origins """ origins( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String """ Filter origins with a URL pattern """ urlPattern: String ): OriginConnection """ Get the visit object with an origin URL and a visit id """ visit( """ URL of the origin """ originUrl: String! """ Visit id to get """ visitId: Int! ): Visit """ Get the snapshot with a SWHID """ snapshot( """ SWHID of the snapshot object """ swhid: SWHID! ): Snapshot """ Get the revision with a SWHID """ revision( """ SWHID of the revision object """ swhid: SWHID! ): Revision """ Get the release with a SWHID """ release( """ SWHID of the release object """ swhid: SWHID! ): Release """ Get the directory with a SWHID """ directory( """ SWHID of the directory object """ swhid: SWHID! ): Directory """ Get a directory entry with directory SWHID and a path """ directoryEntry( """ SWHID of the directory object """ directorySwhid: SWHID! """ Relative path to the requested object """ path: String! ): DirectoryEntry """ Get the content with a SWHID """ content( """ SWHID of the content object """ swhid: SWHID! ): Content """ Get the content by one or more hashes Use multiple hashes for an accurate result """ contentByHash( """ List of hashType:hashValue strings """ checksums: [ContentHash]! ): Content """ Resolve the given SWHID to an object """ resolveSwhid( """ SWHID to look for """ swhid: SWHID! ): SearchResultConnection! """ Search in SWH """ search( """ String to search for """ query: String! """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): SearchResultConnection! } diff --git a/swh/graphql/tests/functional/test_branch_connection.py b/swh/graphql/tests/functional/test_branch_connection.py index e636daf..ee35bf0 100644 --- a/swh/graphql/tests/functional/test_branch_connection.py +++ b/swh/graphql/tests/functional/test_branch_connection.py @@ -1,147 +1,155 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest from . import utils def get_branches(client, swhid: str, first: int, **args) -> tuple: args["first"] = first params = utils.get_query_params_from_args(**args) query_str = """ { snapshot(swhid: "%s") { branches(%s) { pageInfo { endCursor } edges { cursor } nodes { targetType name { text } target { __typename ...on Branch { name { text } } ...on Revision { swhid } ...on Release { swhid } ...on Content { swhid } ...on Directory { swhid } ...on Snapshot { swhid } } } } } } """ % ( swhid, params, ) return utils.get_query_response(client, query_str) def test_get_data(client): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" data, errors = get_branches(client, swhid, 10, types="[revision]") assert len(data["snapshot"]["branches"]["nodes"]) == 1 # filter 'type' will return a single revision object and is used to assert data node = data["snapshot"]["branches"]["nodes"][0] assert node == { "name": {"text": "target/revision"}, "target": { "__typename": "Revision", "swhid": "swh:1:rev:66c7c1cd9673275037140f2abff7b7b11fc9439c", }, "targetType": "revision", } def test_get_branches_with_alias(client): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" data, _ = get_branches(client, swhid, 10, types="[alias]") node = data["snapshot"]["branches"]["nodes"][0] assert node == { "name": {"text": "target/alias"}, "target": {"__typename": "Branch", "name": {"text": "target/revision"}}, "targetType": "alias", } @pytest.mark.parametrize( "filter_type, count, target_type, swhid_pattern", [ ("revision", 1, "Revision", "swh:1:rev"), ("release", 1, "Release", "swh:1:rel"), ("directory", 1, "Directory", "swh:1:dir"), ("content", 0, "Content", "swh:1:cnt"), ("snapshot", 1, "Snapshot", "swh:1:snp"), ], ) def test_get_type_filter(client, filter_type, count, target_type, swhid_pattern): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" data, _ = get_branches(client, swhid, 10, types=f"[{filter_type}]") assert len(data["snapshot"]["branches"]["nodes"]) == count for node in data["snapshot"]["branches"]["nodes"]: assert node["target"]["__typename"] == target_type assert node["target"]["swhid"].startswith(swhid_pattern) @pytest.mark.parametrize( "filter_types, count", [ ("revision, release", 2), ("revision, snapshot, release", 3), ], ) def test_get_type_filter_multiple(client, filter_types, count): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" data, _ = get_branches(client, swhid, 10, types=f"[{filter_types}]") assert len(data["snapshot"]["branches"]["nodes"]) == count @pytest.mark.parametrize("name", ["rel", "rev", "non-exist"]) def test_get_name_include_filter(client, name): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" data, _ = get_branches(client, swhid, 10, nameInclude=f'"{name}"') for node in data["snapshot"]["branches"]["nodes"]: assert name in node["name"]["text"] +@pytest.mark.parametrize("name", ["target", "target/dir"]) +def test_get_name_exclude_prefix_filter(client, name): + swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" + data, _ = get_branches(client, swhid, 10, nameExcludePrefix=f'"{name}"') + for node in data["snapshot"]["branches"]["nodes"]: + assert not node["name"]["text"].startswith(name) + + @pytest.mark.parametrize("count", [1, 2]) def test_get_first_arg(client, count): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" data, _ = get_branches(client, swhid, first=count) assert len(data["snapshot"]["branches"]["nodes"]) == count def test_get_after_arg(client): swhid = "swh:1:snp:0e7f84ede9a254f2cd55649ad5240783f557e65f" first_data, _ = get_branches(client, swhid, first=1) end_cursor = first_data["snapshot"]["branches"]["pageInfo"]["endCursor"] node_name = first_data["snapshot"]["branches"]["nodes"][0]["name"]["text"] second_data, _ = get_branches(client, swhid, first=3, after=f'"{end_cursor}"') branches = second_data["snapshot"]["branches"] assert len(branches["nodes"]) == 3 assert branches["edges"][0]["cursor"] == end_cursor for node in branches["nodes"]: assert node["name"]["text"] > node_name