This commit is contained in:
Christian Mantha
2026-03-02 19:10:52 -05:00
commit 2ca0b9ef7c
28907 changed files with 5233713 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
"""The graphql_relay package"""
# The graphql-relay and graphql-relay-js version info
from .version import version, version_info, version_js, version_info_js
# Types and helpers for creating connection types in the schema
from .connection.connection import (
backward_connection_args,
connection_args,
connection_definitions,
forward_connection_args,
page_info_type,
Connection,
ConnectionArguments,
ConnectionConstructor,
ConnectionCursor,
ConnectionType,
Edge,
EdgeConstructor,
EdgeType,
GraphQLConnectionDefinitions,
PageInfo,
PageInfoConstructor,
PageInfoType,
)
# Helpers for creating connections from arrays
from .connection.array_connection import (
connection_from_array,
connection_from_array_slice,
cursor_for_object_in_connection,
cursor_to_offset,
get_offset_with_default,
offset_to_cursor,
SizedSliceable,
)
# Helper for creating mutations with client mutation IDs
from .mutation.mutation import (
mutation_with_client_mutation_id,
MutationFn,
MutationFnWithoutArgs,
NullResult,
)
# Helper for creating node definitions
from .node.node import node_definitions, GraphQLNodeDefinitions
# Helper for creating plural identifying root fields
from .node.plural import plural_identifying_root_field
# Utilities for creating global IDs in systems that don't have them
from .node.node import from_global_id, global_id_field, to_global_id, ResolvedGlobalId
__version__ = version
__version_info__ = version_info
__version_js__ = version_js
__version_info_js__ = version_info_js
__all__ = [
"backward_connection_args",
"Connection",
"ConnectionArguments",
"ConnectionConstructor",
"ConnectionCursor",
"ConnectionType",
"connection_args",
"connection_from_array",
"connection_from_array_slice",
"connection_definitions",
"cursor_for_object_in_connection",
"cursor_to_offset",
"Edge",
"EdgeConstructor",
"EdgeType",
"forward_connection_args",
"from_global_id",
"get_offset_with_default",
"global_id_field",
"GraphQLConnectionDefinitions",
"GraphQLNodeDefinitions",
"MutationFn",
"MutationFnWithoutArgs",
"mutation_with_client_mutation_id",
"node_definitions",
"NullResult",
"offset_to_cursor",
"PageInfo",
"PageInfoConstructor",
"PageInfoType",
"page_info_type",
"plural_identifying_root_field",
"ResolvedGlobalId",
"SizedSliceable",
"to_global_id",
"version",
"version_info",
"version_js",
"version_info_js",
]

View File

@@ -0,0 +1 @@
"""graphql_relay.connection"""

View File

@@ -0,0 +1,215 @@
from typing import Any, Iterator, Optional, Sequence
try:
from typing import Protocol
except ImportError: # Python < 3.8
from typing_extensions import Protocol # type: ignore
from ..utils.base64 import base64, unbase64
from .connection import (
Connection,
ConnectionArguments,
ConnectionConstructor,
ConnectionCursor,
ConnectionType,
Edge,
EdgeConstructor,
PageInfo,
PageInfoConstructor,
)
__all__ = [
"connection_from_array",
"connection_from_array_slice",
"cursor_for_object_in_connection",
"cursor_to_offset",
"get_offset_with_default",
"offset_to_cursor",
"SizedSliceable",
]
class SizedSliceable(Protocol):
def __getitem__(self, index: slice) -> Any:
...
def __iter__(self) -> Iterator:
...
def __len__(self) -> int:
...
def connection_from_array(
data: SizedSliceable,
args: Optional[ConnectionArguments] = None,
connection_type: ConnectionConstructor = Connection,
edge_type: EdgeConstructor = Edge,
page_info_type: PageInfoConstructor = PageInfo,
) -> ConnectionType:
"""Create a connection object from a sequence of objects.
Note that different from its JavaScript counterpart which expects an array,
this function accepts any kind of sliceable object with a length.
Given this `data` object representing the result set, and connection arguments,
this simple function returns a connection object for use in GraphQL. It uses
offsets as pagination, so pagination will only work if the data is static.
The result will use the default types provided in the `connectiontypes` module
if you don't pass custom types as arguments.
"""
return connection_from_array_slice(
data,
args,
slice_start=0,
array_length=len(data),
connection_type=connection_type,
edge_type=edge_type,
page_info_type=page_info_type,
)
def connection_from_array_slice(
array_slice: SizedSliceable,
args: Optional[ConnectionArguments] = None,
slice_start: int = 0,
array_length: Optional[int] = None,
array_slice_length: Optional[int] = None,
connection_type: ConnectionConstructor = Connection,
edge_type: EdgeConstructor = Edge,
page_info_type: PageInfoConstructor = PageInfo,
) -> ConnectionType:
"""Create a connection object from a slice of the result set.
Note that different from its JavaScript counterpart which expects an array,
this function accepts any kind of sliceable object. This object represents
a slice of the full result set. You need to pass the start position of the
slice as `slice start` and the length of the full result set as `array_length`.
If the `array_slice` does not have a length, you need to provide it separately
in `array_slice_length` as well.
This function is similar to `connection_from_array`, but is intended for use
cases where you know the cardinality of the connection, consider it too large
to materialize the entire result set, and instead wish to pass in only a slice
of the total result large enough to cover the range specified in `args`.
If you do not provide a `slice_start`, we assume that the slice starts at
the beginning of the result set, and if you do not provide an `array_length`,
we assume that the slice ends at the end of the result set.
"""
args = args or {}
before = args.get("before")
after = args.get("after")
first = args.get("first")
last = args.get("last")
if array_slice_length is None:
array_slice_length = len(array_slice)
slice_end = slice_start + array_slice_length
if array_length is None:
array_length = slice_end
start_offset = max(slice_start, 0)
end_offset = min(slice_end, array_length)
after_offset = get_offset_with_default(after, -1)
if 0 <= after_offset < array_length:
start_offset = max(start_offset, after_offset + 1)
before_offset = get_offset_with_default(before, end_offset)
if 0 <= before_offset < array_length:
end_offset = min(end_offset, before_offset)
if isinstance(first, int):
if first < 0:
raise ValueError("Argument 'first' must be a non-negative integer.")
end_offset = min(end_offset, start_offset + first)
if isinstance(last, int):
if last < 0:
raise ValueError("Argument 'last' must be a non-negative integer.")
start_offset = max(start_offset, end_offset - last)
# If supplied slice is too large, trim it down before mapping over it.
trimmed_slice = array_slice[start_offset - slice_start : end_offset - slice_start]
edges = [
edge_type(node=value, cursor=offset_to_cursor(start_offset + index))
for index, value in enumerate(trimmed_slice)
]
first_edge_cursor = edges[0].cursor if edges else None
last_edge_cursor = edges[-1].cursor if edges else None
lower_bound = after_offset + 1 if after else 0
upper_bound = before_offset if before else array_length
return connection_type(
edges=edges,
pageInfo=page_info_type(
startCursor=first_edge_cursor,
endCursor=last_edge_cursor,
hasPreviousPage=isinstance(last, int) and start_offset > lower_bound,
hasNextPage=isinstance(first, int) and end_offset < upper_bound,
),
)
PREFIX = "arrayconnection:"
def offset_to_cursor(offset: int) -> ConnectionCursor:
"""Create the cursor string from an offset."""
return base64(f"{PREFIX}{offset}")
def cursor_to_offset(cursor: ConnectionCursor) -> Optional[int]:
"""Extract the offset from the cursor string."""
try:
return int(unbase64(cursor)[len(PREFIX) :])
except ValueError:
return None
def cursor_for_object_in_connection(
data: Sequence, obj: Any
) -> Optional[ConnectionCursor]:
"""Return the cursor associated with an object in a sequence.
This function uses the `index` method of the sequence if it exists,
otherwise searches the object by iterating via the `__getitem__` method.
"""
try:
offset = data.index(obj)
except AttributeError:
# data does not have an index method
offset = 0
try:
while True:
if data[offset] == obj:
break
offset += 1
except IndexError:
return None
else:
return offset_to_cursor(offset)
except ValueError:
return None
else:
return offset_to_cursor(offset)
def get_offset_with_default(
cursor: Optional[ConnectionCursor] = None, default_offset: int = 0
) -> int:
"""Get offset from a given cursor and a default.
Given an optional cursor and a default offset, return the offset to use;
if the cursor contains a valid offset, that will be used,
otherwise it will be the default.
"""
if not isinstance(cursor, str):
return default_offset
offset = cursor_to_offset(cursor)
return default_offset if offset is None else offset

View File

@@ -0,0 +1,29 @@
import warnings
# noinspection PyDeprecation
from .array_connection import (
connection_from_array,
connection_from_array_slice,
cursor_for_object_in_connection,
cursor_to_offset,
get_offset_with_default,
offset_to_cursor,
SizedSliceable,
)
warnings.warn(
"The 'arrayconnection' module is deprecated. "
"Functions should be imported from the top-level package instead.",
DeprecationWarning,
stacklevel=2,
)
__all__ = [
"connection_from_array",
"connection_from_array_slice",
"cursor_for_object_in_connection",
"cursor_to_offset",
"get_offset_with_default",
"offset_to_cursor",
"SizedSliceable",
]

View File

@@ -0,0 +1,257 @@
from typing import Any, Dict, List, NamedTuple, Optional, Union
from graphql import (
get_named_type,
resolve_thunk,
GraphQLArgument,
GraphQLArgumentMap,
GraphQLBoolean,
GraphQLField,
GraphQLFieldResolver,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
ThunkMapping,
)
from graphql import GraphQLNamedOutputType
try:
from typing import Protocol
except ImportError: # Python < 3.8
from typing_extensions import Protocol # type: ignore
__all__ = [
"backward_connection_args",
"connection_args",
"connection_definitions",
"forward_connection_args",
"page_info_type",
"Connection",
"ConnectionArguments",
"ConnectionConstructor",
"ConnectionCursor",
"ConnectionType",
"Edge",
"EdgeConstructor",
"EdgeType",
"GraphQLConnectionDefinitions",
"PageInfo",
"PageInfoConstructor",
"PageInfoType",
]
# Returns a GraphQLArgumentMap appropriate to include on a field
# whose return type is a connection type with forward pagination.
forward_connection_args: GraphQLArgumentMap = {
"after": GraphQLArgument(
GraphQLString,
description="Returns the items in the list"
" that come after the specified cursor.",
),
"first": GraphQLArgument(
GraphQLInt,
description="Returns the first n items from the list.",
),
}
# Returns a GraphQLArgumentMap appropriate to include on a field
# whose return type is a connection type with backward pagination.
backward_connection_args: GraphQLArgumentMap = {
"before": GraphQLArgument(
GraphQLString,
description="Returns the items in the list"
" that come before the specified cursor.",
),
"last": GraphQLArgument(
GraphQLInt, description="Returns the last n items from the list."
),
}
# Returns a GraphQLArgumentMap appropriate to include on a field
# whose return type is a connection type with bidirectional pagination.
connection_args = {**forward_connection_args, **backward_connection_args}
class GraphQLConnectionDefinitions(NamedTuple):
edge_type: GraphQLObjectType
connection_type: GraphQLObjectType
"""A type alias for cursors in this implementation."""
ConnectionCursor = str
"""A type describing the arguments a connection field receives in GraphQL.
The following kinds of arguments are expected (all optional):
before: ConnectionCursor
after: ConnectionCursor
first: int
last: int
"""
ConnectionArguments = Dict[str, Any]
def connection_definitions(
node_type: Union[GraphQLNamedOutputType, GraphQLNonNull[GraphQLNamedOutputType]],
name: Optional[str] = None,
resolve_node: Optional[GraphQLFieldResolver] = None,
resolve_cursor: Optional[GraphQLFieldResolver] = None,
edge_fields: Optional[ThunkMapping[GraphQLField]] = None,
connection_fields: Optional[ThunkMapping[GraphQLField]] = None,
) -> GraphQLConnectionDefinitions:
"""Return GraphQLObjectTypes for a connection with the given name.
The nodes of the returned object types will be of the specified type.
"""
name = name or get_named_type(node_type).name
edge_type = GraphQLObjectType(
name + "Edge",
description="An edge in a connection.",
fields=lambda: {
"node": GraphQLField(
node_type,
resolve=resolve_node,
description="The item at the end of the edge",
),
"cursor": GraphQLField(
GraphQLNonNull(GraphQLString),
resolve=resolve_cursor,
description="A cursor for use in pagination",
),
**resolve_thunk(edge_fields or {}),
},
)
connection_type = GraphQLObjectType(
name + "Connection",
description="A connection to a list of items.",
fields=lambda: {
"pageInfo": GraphQLField(
GraphQLNonNull(page_info_type),
description="Information to aid in pagination.",
),
"edges": GraphQLField(
GraphQLList(edge_type), description="A list of edges."
),
**resolve_thunk(connection_fields or {}),
},
)
return GraphQLConnectionDefinitions(edge_type, connection_type)
class PageInfoType(Protocol):
@property
def startCursor(self) -> Optional[ConnectionCursor]:
...
def endCursor(self) -> Optional[ConnectionCursor]:
...
def hasPreviousPage(self) -> bool:
...
def hasNextPage(self) -> bool:
...
class PageInfoConstructor(Protocol):
def __call__(
self,
*,
startCursor: Optional[ConnectionCursor],
endCursor: Optional[ConnectionCursor],
hasPreviousPage: bool,
hasNextPage: bool,
) -> PageInfoType:
...
class PageInfo(NamedTuple):
"""A type designed to be exposed as `PageInfo` over GraphQL."""
startCursor: Optional[ConnectionCursor]
endCursor: Optional[ConnectionCursor]
hasPreviousPage: bool
hasNextPage: bool
class EdgeType(Protocol):
@property
def node(self) -> Any:
...
@property
def cursor(self) -> ConnectionCursor:
...
class EdgeConstructor(Protocol):
def __call__(self, *, node: Any, cursor: ConnectionCursor) -> EdgeType:
...
class Edge(NamedTuple):
"""A type designed to be exposed as a `Edge` over GraphQL."""
node: Any
cursor: ConnectionCursor
class ConnectionType(Protocol):
@property
def edges(self) -> List[EdgeType]:
...
@property
def pageInfo(self) -> PageInfoType:
...
class ConnectionConstructor(Protocol):
def __call__(
self,
*,
edges: List[EdgeType],
pageInfo: PageInfoType,
) -> ConnectionType:
...
class Connection(NamedTuple):
"""A type designed to be exposed as a `Connection` over GraphQL."""
edges: List[Edge]
pageInfo: PageInfo
# The common page info type used by all connections.
page_info_type = GraphQLObjectType(
"PageInfo",
description="Information about pagination in a connection.",
fields=lambda: {
"hasNextPage": GraphQLField(
GraphQLNonNull(GraphQLBoolean),
description="When paginating forwards, are there more items?",
),
"hasPreviousPage": GraphQLField(
GraphQLNonNull(GraphQLBoolean),
description="When paginating backwards, are there more items?",
),
"startCursor": GraphQLField(
GraphQLString,
description="When paginating backwards, the cursor to continue.",
),
"endCursor": GraphQLField(
GraphQLString,
description="When paginating forwards, the cursor to continue.",
),
},
)

View File

@@ -0,0 +1 @@
"""graphql_relay.mutation"""

View File

@@ -0,0 +1,119 @@
from collections.abc import Mapping
from inspect import iscoroutinefunction
from typing import Any, Callable, Dict, Optional
from graphql import (
resolve_thunk,
GraphQLArgument,
GraphQLField,
GraphQLFieldMap,
GraphQLInputField,
GraphQLInputFieldMap,
GraphQLInputObjectType,
GraphQLNonNull,
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLString,
ThunkMapping,
)
from graphql.pyutils import AwaitableOrValue
__all__ = [
"mutation_with_client_mutation_id",
"MutationFn",
"MutationFnWithoutArgs",
"NullResult",
]
# Note: Contrary to the Javascript implementation of MutationFn,
# the context is passed as part of the GraphQLResolveInfo and any arguments
# are passed individually as keyword arguments.
MutationFnWithoutArgs = Callable[[GraphQLResolveInfo], AwaitableOrValue[Any]]
# Unfortunately there is currently no syntax to indicate optional or keyword
# arguments in Python, so we also allow any other Callable as a workaround:
MutationFn = Callable[..., AwaitableOrValue[Any]]
class NullResult:
def __init__(self, clientMutationId: Optional[str] = None) -> None:
self.clientMutationId = clientMutationId
def mutation_with_client_mutation_id(
name: str,
input_fields: ThunkMapping[GraphQLInputField],
output_fields: ThunkMapping[GraphQLField],
mutate_and_get_payload: MutationFn,
description: Optional[str] = None,
deprecation_reason: Optional[str] = None,
extensions: Optional[Dict[str, Any]] = None,
) -> GraphQLField:
"""
Returns a GraphQLFieldConfig for the specified mutation.
The input_fields and output_fields should not include `clientMutationId`,
as this will be provided automatically.
An input object will be created containing the input fields, and an
object will be created containing the output fields.
mutate_and_get_payload will receive a GraphQLResolveInfo as first argument,
and the input fields as keyword arguments, and it should return an object
(or a dict) with an attribute (or a key) for each output field.
It may return synchronously or asynchronously.
"""
def augmented_input_fields() -> GraphQLInputFieldMap:
return dict(
resolve_thunk(input_fields),
clientMutationId=GraphQLInputField(GraphQLString),
)
def augmented_output_fields() -> GraphQLFieldMap:
return dict(
resolve_thunk(output_fields),
clientMutationId=GraphQLField(GraphQLString),
)
output_type = GraphQLObjectType(name + "Payload", fields=augmented_output_fields)
input_type = GraphQLInputObjectType(name + "Input", fields=augmented_input_fields)
if iscoroutinefunction(mutate_and_get_payload):
# noinspection PyShadowingBuiltins
async def resolve(_root: Any, info: GraphQLResolveInfo, input: Dict) -> Any:
payload = await mutate_and_get_payload(info, **input)
clientMutationId = input.get("clientMutationId")
if payload is None:
return NullResult(clientMutationId)
if isinstance(payload, Mapping):
payload["clientMutationId"] = clientMutationId # type: ignore
else:
payload.clientMutationId = clientMutationId
return payload
else:
# noinspection PyShadowingBuiltins
def resolve( # type: ignore
_root: Any, info: GraphQLResolveInfo, input: Dict
) -> Any:
payload = mutate_and_get_payload(info, **input)
clientMutationId = input.get("clientMutationId")
if payload is None:
return NullResult(clientMutationId)
if isinstance(payload, Mapping):
payload["clientMutationId"] = clientMutationId # type: ignore
else:
payload.clientMutationId = clientMutationId # type: ignore
return payload
return GraphQLField(
output_type,
description=description,
deprecation_reason=deprecation_reason,
args={"input": GraphQLArgument(GraphQLNonNull(input_type))},
resolve=resolve,
extensions=extensions,
)

View File

@@ -0,0 +1 @@
"""graphql_relay.node"""

View File

@@ -0,0 +1,132 @@
from typing import Any, Callable, NamedTuple, Optional, Union
from graphql_relay.utils.base64 import base64, unbase64
from graphql import (
GraphQLArgument,
GraphQLNonNull,
GraphQLID,
GraphQLField,
GraphQLInterfaceType,
GraphQLList,
GraphQLResolveInfo,
GraphQLTypeResolver,
)
__all__ = [
"from_global_id",
"global_id_field",
"node_definitions",
"to_global_id",
"GraphQLNodeDefinitions",
"ResolvedGlobalId",
]
class GraphQLNodeDefinitions(NamedTuple):
node_interface: GraphQLInterfaceType
node_field: GraphQLField
nodes_field: GraphQLField
def node_definitions(
fetch_by_id: Callable[[str, GraphQLResolveInfo], Any],
type_resolver: Optional[GraphQLTypeResolver] = None,
) -> GraphQLNodeDefinitions:
"""
Given a function to map from an ID to an underlying object, and a function
to map from an underlying object to the concrete GraphQLObjectType it
corresponds to, constructs a `Node` interface that objects can implement,
and a field object to be used as a `node` root field.
If the type_resolver is omitted, object resolution on the interface will be
handled with the `is_type_of` method on object types, as with any GraphQL
interface without a provided `resolve_type` method.
"""
node_interface = GraphQLInterfaceType(
"Node",
description="An object with an ID",
fields=lambda: {
"id": GraphQLField(
GraphQLNonNull(GraphQLID), description="The id of the object."
)
},
resolve_type=type_resolver,
)
# noinspection PyShadowingBuiltins
node_field = GraphQLField(
node_interface,
description="Fetches an object given its ID",
args={
"id": GraphQLArgument(
GraphQLNonNull(GraphQLID), description="The ID of an object"
)
},
resolve=lambda _obj, info, id: fetch_by_id(id, info),
)
nodes_field = GraphQLField(
GraphQLNonNull(GraphQLList(node_interface)),
description="Fetches objects given their IDs",
args={
"ids": GraphQLArgument(
GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLID))),
description="The IDs of objects",
)
},
resolve=lambda _obj, info, ids: [fetch_by_id(id_, info) for id_ in ids],
)
return GraphQLNodeDefinitions(node_interface, node_field, nodes_field)
class ResolvedGlobalId(NamedTuple):
type: str
id: str
def to_global_id(type_: str, id_: Union[str, int]) -> str:
"""
Takes a type name and an ID specific to that type name, and returns a
"global ID" that is unique among all types.
"""
return base64(f"{type_}:{GraphQLID.serialize(id_)}")
def from_global_id(global_id: str) -> ResolvedGlobalId:
"""
Takes the "global ID" created by to_global_id, and returns the type name and ID
used to create it.
"""
global_id = unbase64(global_id)
if ":" not in global_id:
return ResolvedGlobalId("", global_id)
return ResolvedGlobalId(*global_id.split(":", 1))
def global_id_field(
type_name: Optional[str] = None,
id_fetcher: Optional[Callable[[Any, GraphQLResolveInfo], str]] = None,
) -> GraphQLField:
"""
Creates the configuration for an id field on a node, using `to_global_id` to
construct the ID from the provided typename. The type-specific ID is fetched
by calling id_fetcher on the object, or if not provided, by accessing the `id`
attribute of the object, or the `id` if the object is a dict.
"""
def resolve(obj: Any, info: GraphQLResolveInfo, **_args: Any) -> str:
type_ = type_name or info.parent_type.name
id_ = (
id_fetcher(obj, info)
if id_fetcher
else (obj["id"] if isinstance(obj, dict) else obj.id)
)
return to_global_id(type_, id_)
return GraphQLField(
GraphQLNonNull(GraphQLID), description="The ID of an object", resolve=resolve
)

View File

@@ -0,0 +1,41 @@
from typing import Any, Callable, List, Optional
from graphql import (
GraphQLArgument,
GraphQLField,
GraphQLInputType,
GraphQLOutputType,
GraphQLList,
GraphQLNonNull,
GraphQLResolveInfo,
get_nullable_type,
)
__all__ = ["plural_identifying_root_field"]
def plural_identifying_root_field(
arg_name: str,
input_type: GraphQLInputType,
output_type: GraphQLOutputType,
resolve_single_input: Callable[[GraphQLResolveInfo, str], Any],
description: Optional[str] = None,
) -> GraphQLField:
def resolve(_obj: Any, info: GraphQLResolveInfo, **args: Any) -> List:
inputs = args[arg_name]
return [resolve_single_input(info, input_) for input_ in inputs]
return GraphQLField(
GraphQLList(output_type),
description=description,
args={
arg_name: GraphQLArgument(
GraphQLNonNull(
GraphQLList(
GraphQLNonNull(get_nullable_type(input_type)) # type: ignore
)
)
)
},
resolve=resolve,
)

View File

@@ -0,0 +1 @@
# Marker file for PEP 561. The graphql package uses inline types.

View File

@@ -0,0 +1,5 @@
"""graphql_relay.utils"""
from .base64 import base64, unbase64
__all__ = ["base64", "unbase64"]

View File

@@ -0,0 +1,24 @@
from base64 import b64encode, b64decode
import binascii
__all__ = ["base64", "unbase64"]
Base64String = str
def base64(s: str) -> Base64String:
"""Encode the string s using Base64."""
b: bytes = s.encode("utf-8") if isinstance(s, str) else s
return b64encode(b).decode("ascii")
def unbase64(s: Base64String) -> str:
"""Decode the string s using Base64."""
try:
b: bytes = s.encode("ascii") if isinstance(s, str) else s
except UnicodeEncodeError:
return ""
try:
return b64decode(b).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return ""

View File

@@ -0,0 +1,51 @@
import re
from typing import NamedTuple
__all__ = ["version", "version_info", "version_js", "version_info_js"]
version = "3.2.0"
version_js = "0.10.0"
_re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)")
class VersionInfo(NamedTuple):
major: int
minor: int
micro: int
releaselevel: str
serial: int
@classmethod
def from_str(cls, v: str) -> "VersionInfo":
groups = _re_version.match(v).groups() # type: ignore
major, minor, micro = map(int, groups[:3])
level = (groups[3] or "")[:1]
if level == "a":
level = "alpha"
elif level == "b":
level = "beta"
elif level in ("c", "r"):
level = "candidate"
else:
level = "final"
serial = groups[4]
serial = int(serial) if serial else 0
return cls(major, minor, micro, level, serial)
def __str__(self) -> str:
v = f"{self.major}.{self.minor}.{self.micro}"
level = self.releaselevel
if level and level != "final":
level = level[:1]
if level == "c":
level = "rc"
v = f"{v}{level}{self.serial}"
return v
version_info = VersionInfo.from_str(version)
version_info_js = VersionInfo.from_str(version_js)