chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
View File
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Konrad Hałas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3
View File
@@ -0,0 +1,3 @@
from .config import Config
from .core import from_dict
from .exceptions import *
+12
View File
@@ -0,0 +1,12 @@
from dataclasses import dataclass, field
from typing import Dict, Any, Callable, Optional, Type, List
@dataclass
class Config:
type_hooks: Dict[Type, Callable[[Any], Any]] = field(default_factory=dict)
cast: List[Type] = field(default_factory=list)
forward_references: Optional[Dict[str, Any]] = None
check_types: bool = True
strict: bool = False
strict_unions_match: bool = False
+140
View File
@@ -0,0 +1,140 @@
import copy
from dataclasses import is_dataclass
from itertools import zip_longest
from typing import TypeVar, Type, Optional, get_type_hints, Mapping, Any
from .config import Config
from .data import Data
from .dataclasses import get_default_value_for_field, create_instance, DefaultValueNotFoundError, get_fields
from .exceptions import (
ForwardReferenceError,
WrongTypeError,
DaciteError,
UnionMatchError,
MissingValueError,
DaciteFieldError,
UnexpectedDataError,
StrictUnionMatchError,
)
from .types import (
is_instance,
is_generic_collection,
is_union,
extract_generic,
is_optional,
transform_value,
extract_origin_collection,
is_init_var,
extract_init_var,
)
T = TypeVar("T")
def from_dict(data_class: Type[T], data: Data, config: Optional[Config] = None) -> T:
"""Create a data class instance from a dictionary.
:param data_class: a data class type
:param data: a dictionary of a input data
:param config: a configuration of the creation process
:return: an instance of a data class
"""
init_values: Data = {}
post_init_values: Data = {}
config = config or Config()
try:
data_class_hints = get_type_hints(data_class, globalns=config.forward_references)
except NameError as error:
raise ForwardReferenceError(str(error))
data_class_fields = get_fields(data_class)
if config.strict:
extra_fields = set(data.keys()) - {f.name for f in data_class_fields}
if extra_fields:
raise UnexpectedDataError(keys=extra_fields)
for field in data_class_fields:
field = copy.copy(field)
field.type = data_class_hints[field.name]
try:
try:
field_data = data[field.name]
transformed_value = transform_value(
type_hooks=config.type_hooks, cast=config.cast, target_type=field.type, value=field_data
)
value = _build_value(type_=field.type, data=transformed_value, config=config)
except DaciteFieldError as error:
error.update_path(field.name)
raise
if config.check_types and not is_instance(value, field.type):
raise WrongTypeError(field_path=field.name, field_type=field.type, value=value)
except KeyError:
try:
value = get_default_value_for_field(field)
except DefaultValueNotFoundError:
if not field.init:
continue
raise MissingValueError(field.name)
if field.init:
init_values[field.name] = value
else:
post_init_values[field.name] = value
return create_instance(data_class=data_class, init_values=init_values, post_init_values=post_init_values)
def _build_value(type_: Type, data: Any, config: Config) -> Any:
if is_init_var(type_):
type_ = extract_init_var(type_)
if is_union(type_):
return _build_value_for_union(union=type_, data=data, config=config)
elif is_generic_collection(type_) and is_instance(data, extract_origin_collection(type_)):
return _build_value_for_collection(collection=type_, data=data, config=config)
elif is_dataclass(type_) and is_instance(data, Data):
return from_dict(data_class=type_, data=data, config=config)
return data
def _build_value_for_union(union: Type, data: Any, config: Config) -> Any:
types = extract_generic(union)
if is_optional(union) and len(types) == 2:
return _build_value(type_=types[0], data=data, config=config)
union_matches = {}
for inner_type in types:
try:
# noinspection PyBroadException
try:
data = transform_value(
type_hooks=config.type_hooks, cast=config.cast, target_type=inner_type, value=data
)
except Exception: # pylint: disable=broad-except
continue
value = _build_value(type_=inner_type, data=data, config=config)
if is_instance(value, inner_type):
if config.strict_unions_match:
union_matches[inner_type] = value
else:
return value
except DaciteError:
pass
if config.strict_unions_match:
if len(union_matches) > 1:
raise StrictUnionMatchError(union_matches)
return union_matches.popitem()[1]
if not config.check_types:
return data
raise UnionMatchError(field_type=union, value=data)
def _build_value_for_collection(collection: Type, data: Any, config: Config) -> Any:
data_type = data.__class__
if is_instance(data, Mapping):
item_type = extract_generic(collection, defaults=(Any, Any))[1]
return data_type((key, _build_value(type_=item_type, data=value, config=config)) for key, value in data.items())
elif is_instance(data, tuple):
types = extract_generic(collection)
if len(types) == 2 and types[1] == Ellipsis:
return data_type(_build_value(type_=types[0], data=item, config=config) for item in data)
return data_type(
_build_value(type_=type_, data=item, config=config) for item, type_ in zip_longest(data, types)
)
item_type = extract_generic(collection, defaults=(Any,))[0]
return data_type(_build_value(type_=item_type, data=item, config=config) for item in data)
+3
View File
@@ -0,0 +1,3 @@
from typing import Dict, Any
Data = Dict[str, Any]
+33
View File
@@ -0,0 +1,33 @@
from dataclasses import Field, MISSING, _FIELDS, _FIELD, _FIELD_INITVAR # type: ignore
from typing import Type, Any, TypeVar, List
from .data import Data
from .types import is_optional
T = TypeVar("T", bound=Any)
class DefaultValueNotFoundError(Exception):
pass
def get_default_value_for_field(field: Field) -> Any:
if field.default != MISSING:
return field.default
elif field.default_factory != MISSING: # type: ignore
return field.default_factory() # type: ignore
elif is_optional(field.type):
return None
raise DefaultValueNotFoundError()
def create_instance(data_class: Type[T], init_values: Data, post_init_values: Data) -> T:
instance = data_class(**init_values)
for key, value in post_init_values.items():
setattr(instance, key, value)
return instance
def get_fields(data_class: Type[T]) -> List[Field]:
fields = getattr(data_class, _FIELDS)
return [f for f in fields.values() if f._field_type is _FIELD or f._field_type is _FIELD_INITVAR]
+79
View File
@@ -0,0 +1,79 @@
from typing import Any, Type, Optional, Set, Dict
def _name(type_: Type) -> str:
return type_.__name__ if hasattr(type_, "__name__") else str(type_)
class DaciteError(Exception):
pass
class DaciteFieldError(DaciteError):
def __init__(self, field_path: Optional[str] = None):
super().__init__()
self.field_path = field_path
def update_path(self, parent_field_path: str) -> None:
if self.field_path:
self.field_path = f"{parent_field_path}.{self.field_path}"
else:
self.field_path = parent_field_path
class WrongTypeError(DaciteFieldError):
def __init__(self, field_type: Type, value: Any, field_path: Optional[str] = None) -> None:
super().__init__(field_path=field_path)
self.field_type = field_type
self.value = value
def __str__(self) -> str:
return (
f'wrong value type for field "{self.field_path}" - should be "{_name(self.field_type)}" '
f'instead of value "{self.value}" of type "{_name(type(self.value))}"'
)
class MissingValueError(DaciteFieldError):
def __init__(self, field_path: Optional[str] = None):
super().__init__(field_path=field_path)
def __str__(self) -> str:
return f'missing value for field "{self.field_path}"'
class UnionMatchError(WrongTypeError):
def __str__(self) -> str:
return (
f'can not match type "{_name(type(self.value))}" to any type '
f'of "{self.field_path}" union: {_name(self.field_type)}'
)
class StrictUnionMatchError(DaciteFieldError):
def __init__(self, union_matches: Dict[Type, Any], field_path: Optional[str] = None) -> None:
super().__init__(field_path=field_path)
self.union_matches = union_matches
def __str__(self) -> str:
conflicting_types = ", ".join(_name(type_) for type_ in self.union_matches)
return f'can not choose between possible Union matches for field "{self.field_path}": {conflicting_types}'
class ForwardReferenceError(DaciteError):
def __init__(self, message: str) -> None:
super().__init__()
self.message = message
def __str__(self) -> str:
return f"can not resolve forward reference: {self.message}"
class UnexpectedDataError(DaciteError):
def __init__(self, keys: Set[str]) -> None:
super().__init__()
self.keys = keys
def __str__(self) -> str:
formatted_keys = ", ".join(f'"{key}"' for key in self.keys)
return f"can not match {formatted_keys} to any data class field"
View File
+172
View File
@@ -0,0 +1,172 @@
from dataclasses import InitVar
from typing import Type, Any, Optional, Union, Collection, TypeVar, Dict, Callable, Mapping, List, Tuple
T = TypeVar("T", bound=Any)
def transform_value(
type_hooks: Dict[Type, Callable[[Any], Any]], cast: List[Type], target_type: Type, value: Any
) -> Any:
if target_type in type_hooks:
value = type_hooks[target_type](value)
else:
for cast_type in cast:
if is_subclass(target_type, cast_type):
if is_generic_collection(target_type):
value = extract_origin_collection(target_type)(value)
else:
value = target_type(value)
break
if is_optional(target_type):
if value is None:
return None
target_type = extract_optional(target_type)
return transform_value(type_hooks, cast, target_type, value)
if is_generic_collection(target_type) and isinstance(value, extract_origin_collection(target_type)):
collection_cls = value.__class__
if issubclass(collection_cls, dict):
key_cls, item_cls = extract_generic(target_type, defaults=(Any, Any))
return collection_cls(
{
transform_value(type_hooks, cast, key_cls, key): transform_value(type_hooks, cast, item_cls, item)
for key, item in value.items()
}
)
item_cls = extract_generic(target_type, defaults=(Any,))[0]
return collection_cls(transform_value(type_hooks, cast, item_cls, item) for item in value)
return value
def extract_origin_collection(collection: Type) -> Type:
try:
return collection.__extra__
except AttributeError:
return collection.__origin__
def is_optional(type_: Type) -> bool:
return is_union(type_) and type(None) in extract_generic(type_)
def extract_optional(optional: Type[Optional[T]]) -> T:
for type_ in extract_generic(optional):
if type_ is not type(None):
return type_
raise ValueError("can not find not-none value")
def is_generic(type_: Type) -> bool:
return hasattr(type_, "__origin__")
def is_union(type_: Type) -> bool:
return is_generic(type_) and type_.__origin__ == Union
def is_literal(type_: Type) -> bool:
try:
from typing import Literal # type: ignore
return is_generic(type_) and type_.__origin__ == Literal
except ImportError:
return False
def is_new_type(type_: Type) -> bool:
return hasattr(type_, "__supertype__")
def extract_new_type(type_: Type) -> Type:
return type_.__supertype__
def is_init_var(type_: Type) -> bool:
return isinstance(type_, InitVar) or type_ is InitVar
def extract_init_var(type_: Type) -> Union[Type, Any]:
try:
return type_.type
except AttributeError:
return Any
def is_instance(value: Any, type_: Type) -> bool:
if type_ == Any:
return True
elif is_union(type_):
return any(is_instance(value, t) for t in extract_generic(type_))
elif is_generic_collection(type_):
origin = extract_origin_collection(type_)
if not isinstance(value, origin):
return False
if not extract_generic(type_):
return True
if isinstance(value, tuple):
tuple_types = extract_generic(type_)
if len(tuple_types) == 1 and tuple_types[0] == ():
return len(value) == 0
elif len(tuple_types) == 2 and tuple_types[1] is ...:
return all(is_instance(item, tuple_types[0]) for item in value)
else:
if len(tuple_types) != len(value):
return False
return all(is_instance(item, item_type) for item, item_type in zip(value, tuple_types))
if isinstance(value, Mapping):
key_type, val_type = extract_generic(type_, defaults=(Any, Any))
for key, val in value.items():
if not is_instance(key, key_type) or not is_instance(val, val_type):
return False
return True
return all(is_instance(item, extract_generic(type_, defaults=(Any,))[0]) for item in value)
elif is_new_type(type_):
return is_instance(value, extract_new_type(type_))
elif is_literal(type_):
return value in extract_generic(type_)
elif is_init_var(type_):
return is_instance(value, extract_init_var(type_))
elif is_type_generic(type_):
return is_subclass(value, extract_generic(type_)[0])
else:
try:
# As described in PEP 484 - section: "The numeric tower"
if isinstance(value, (int, float)) and type_ in [float, complex]:
return True
return isinstance(value, type_)
except TypeError:
return False
def is_generic_collection(type_: Type) -> bool:
if not is_generic(type_):
return False
origin = extract_origin_collection(type_)
try:
return bool(origin and issubclass(origin, Collection))
except (TypeError, AttributeError):
return False
def extract_generic(type_: Type, defaults: Tuple = ()) -> tuple:
try:
if hasattr(type_, "_special") and type_._special:
return defaults
return type_.__args__ or defaults # type: ignore
except AttributeError:
return defaults
def is_subclass(sub_type: Type, base_type: Type) -> bool:
if is_generic_collection(sub_type):
sub_type = extract_origin_collection(sub_type)
try:
return issubclass(sub_type, base_type)
except TypeError:
return False
def is_type_generic(type_: Type) -> bool:
try:
return type_.__origin__ in (type, Type)
except AttributeError:
return False
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+69
View File
@@ -0,0 +1,69 @@
# encoding: utf-8
"""
The *pathspec* package provides pattern matching for file paths. So far
this only includes Git's wildmatch pattern matching (the style used for
".gitignore" files).
The following classes are imported and made available from the root of
the `pathspec` package:
- :class:`pathspec.pathspec.PathSpec`
- :class:`pathspec.pattern.Pattern`
- :class:`pathspec.pattern.RegexPattern`
- :class:`pathspec.util.RecursionError`
The following functions are also imported:
- :func:`pathspec.util.iter_tree`
- :func:`pathspec.util.lookup_pattern`
- :func:`pathspec.util.match_files`
"""
from __future__ import unicode_literals
__author__ = "Caleb P. Burns"
__copyright__ = "Copyright © 2013-2020 Caleb P. Burns"
__created__ = "2013-10-12"
__credits__ = [
"dahlia <https://github.com/dahlia>",
"highb <https://github.com/highb>",
"029xue <https://github.com/029xue>",
"mikexstudios <https://github.com/mikexstudios>",
"nhumrich <https://github.com/nhumrich>",
"davidfraser <https://github.com/davidfraser>",
"demurgos <https://github.com/demurgos>",
"ghickman <https://github.com/ghickman>",
"nvie <https://github.com/nvie>",
"adrienverge <https://github.com/adrienverge>",
"AndersBlomdell <https://github.com/AndersBlomdell>",
"highb <https://github.com/highb>",
"thmxv <https://github.com/thmxv>",
"wimglenn <https://github.com/wimglenn>",
"hugovk <https://github.com/hugovk>",
"dcecile <https://github.com/dcecile>",
"mroutis <https://github.com/mroutis>",
"jdufresne <https://github.com/jdufresne>",
"groodt <https://github.com/groodt>",
"ftrofin <https://github.com/ftrofin>",
"pykong <https://github.com/pykong>",
"nhhollander <https://github.com/nhhollander>",
]
__email__ = "cpburnz@gmail.com"
__license__ = "MPL 2.0"
__project__ = "pathspec"
__status__ = "Development"
__updated__ = "2020-11-07"
__version__ = "0.8.1"
from .pathspec import PathSpec
from .pattern import Pattern, RegexPattern
from .util import iter_tree, lookup_pattern, match_files, RecursionError
# Load pattern implementations.
from . import patterns
# Expose `GitIgnorePattern` class in the root module for backward
# compatibility with v0.4.
from .patterns.gitwildmatch import GitIgnorePattern
+38
View File
@@ -0,0 +1,38 @@
# encoding: utf-8
"""
This module provides compatibility between Python 2 and 3. Hardly
anything is used by this project to constitute including `six`_.
.. _`six`: http://pythonhosted.org/six
"""
import sys
if sys.version_info[0] < 3:
# Python 2.
unicode = unicode
string_types = (basestring,)
from collections import Iterable
from itertools import izip_longest
def iterkeys(mapping):
return mapping.iterkeys()
else:
# Python 3.
unicode = str
string_types = (unicode,)
from collections.abc import Iterable
from itertools import zip_longest as izip_longest
def iterkeys(mapping):
return mapping.keys()
try:
# Python 3.6+.
from collections.abc import Collection
except ImportError:
# Python 2.7 - 3.5.
from collections import Container as Collection
+206
View File
@@ -0,0 +1,206 @@
# encoding: utf-8
"""
This module provides an object oriented interface for pattern matching
of files.
"""
from . import util
from .compat import Collection, iterkeys, izip_longest, string_types, unicode
class PathSpec(object):
"""
The :class:`PathSpec` class is a wrapper around a list of compiled
:class:`.Pattern` instances.
"""
def __init__(self, patterns):
"""
Initializes the :class:`PathSpec` instance.
*patterns* (:class:`~collections.abc.Collection` or :class:`~collections.abc.Iterable`)
yields each compiled pattern (:class:`.Pattern`).
"""
self.patterns = patterns if isinstance(patterns, Collection) else list(patterns)
"""
*patterns* (:class:`~collections.abc.Collection` of :class:`.Pattern`)
contains the compiled patterns.
"""
def __eq__(self, other):
"""
Tests the equality of this path-spec with *other* (:class:`PathSpec`)
by comparing their :attr:`~PathSpec.patterns` attributes.
"""
if isinstance(other, PathSpec):
paired_patterns = izip_longest(self.patterns, other.patterns)
return all(a == b for a, b in paired_patterns)
else:
return NotImplemented
def __len__(self):
"""
Returns the number of compiled patterns this path-spec contains
(:class:`int`).
"""
return len(self.patterns)
def __add__(self, other):
"""
Combines the :attr:`Pathspec.patterns` patterns from two
:class:`PathSpec` instances.
"""
if isinstance(other, PathSpec):
return PathSpec(self.patterns + other.patterns)
else:
return NotImplemented
def __iadd__(self, other):
"""
Adds the :attr:`Pathspec.patterns` patterns from one :class:`PathSpec`
instance to this instance.
"""
if isinstance(other, PathSpec):
self.patterns += other.patterns
return self
else:
return NotImplemented
@classmethod
def from_lines(cls, pattern_factory, lines):
"""
Compiles the pattern lines.
*pattern_factory* can be either the name of a registered pattern
factory (:class:`str`), or a :class:`~collections.abc.Callable` used
to compile patterns. It must accept an uncompiled pattern (:class:`str`)
and return the compiled pattern (:class:`.Pattern`).
*lines* (:class:`~collections.abc.Iterable`) yields each uncompiled
pattern (:class:`str`). This simply has to yield each line so it can
be a :class:`file` (e.g., from :func:`open` or :class:`io.StringIO`)
or the result from :meth:`str.splitlines`.
Returns the :class:`PathSpec` instance.
"""
if isinstance(pattern_factory, string_types):
pattern_factory = util.lookup_pattern(pattern_factory)
if not callable(pattern_factory):
raise TypeError("pattern_factory:{!r} is not callable.".format(pattern_factory))
if not util._is_iterable(lines):
raise TypeError("lines:{!r} is not an iterable.".format(lines))
lines = [pattern_factory(line) for line in lines if line]
return cls(lines)
def match_file(self, file, separators=None):
"""
Matches the file to this path-spec.
*file* (:class:`str` or :class:`~pathlib.PurePath`) is the file path
to be matched against :attr:`self.patterns <PathSpec.patterns>`.
*separators* (:class:`~collections.abc.Collection` of :class:`str`)
optionally contains the path separators to normalize. See
:func:`~pathspec.util.normalize_file` for more information.
Returns :data:`True` if *file* matched; otherwise, :data:`False`.
"""
norm_file = util.normalize_file(file, separators=separators)
return util.match_file(self.patterns, norm_file)
def match_entries(self, entries, separators=None):
"""
Matches the entries to this path-spec.
*entries* (:class:`~collections.abc.Iterable` of :class:`~util.TreeEntry`)
contains the entries to be matched against :attr:`self.patterns <PathSpec.patterns>`.
*separators* (:class:`~collections.abc.Collection` of :class:`str`;
or :data:`None`) optionally contains the path separators to
normalize. See :func:`~pathspec.util.normalize_file` for more
information.
Returns the matched entries (:class:`~collections.abc.Iterable` of
:class:`~util.TreeEntry`).
"""
if not util._is_iterable(entries):
raise TypeError("entries:{!r} is not an iterable.".format(entries))
entry_map = util._normalize_entries(entries, separators=separators)
match_paths = util.match_files(self.patterns, iterkeys(entry_map))
for path in match_paths:
yield entry_map[path]
def match_files(self, files, separators=None):
"""
Matches the files to this path-spec.
*files* (:class:`~collections.abc.Iterable` of :class:`str; or
:class:`pathlib.PurePath`) contains the file paths to be matched
against :attr:`self.patterns <PathSpec.patterns>`.
*separators* (:class:`~collections.abc.Collection` of :class:`str`;
or :data:`None`) optionally contains the path separators to
normalize. See :func:`~pathspec.util.normalize_file` for more
information.
Returns the matched files (:class:`~collections.abc.Iterable` of
:class:`str`).
"""
if not util._is_iterable(files):
raise TypeError("files:{!r} is not an iterable.".format(files))
file_map = util.normalize_files(files, separators=separators)
matched_files = util.match_files(self.patterns, iterkeys(file_map))
for path in matched_files:
yield file_map[path]
def match_tree_entries(self, root, on_error=None, follow_links=None):
"""
Walks the specified root path for all files and matches them to this
path-spec.
*root* (:class:`str`; or :class:`pathlib.PurePath`) is the root
directory to search.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. See
:func:`~pathspec.util.iter_tree_entries` for more information.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. See
:func:`~pathspec.util.iter_tree_files` for more information.
Returns the matched files (:class:`~collections.abc.Iterable` of
:class:`str`).
"""
entries = util.iter_tree_entries(root, on_error=on_error, follow_links=follow_links)
return self.match_entries(entries)
def match_tree_files(self, root, on_error=None, follow_links=None):
"""
Walks the specified root path for all files and matches them to this
path-spec.
*root* (:class:`str`; or :class:`pathlib.PurePath`) is the root
directory to search for files.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. See
:func:`~pathspec.util.iter_tree_files` for more information.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. See
:func:`~pathspec.util.iter_tree_files` for more information.
Returns the matched files (:class:`~collections.abc.Iterable` of
:class:`str`).
"""
files = util.iter_tree_files(root, on_error=on_error, follow_links=follow_links)
return self.match_files(files)
# Alias `match_tree_files()` as `match_tree()`.
match_tree = match_tree_files
+146
View File
@@ -0,0 +1,146 @@
# encoding: utf-8
"""
This module provides the base definition for patterns.
"""
import re
from .compat import unicode
class Pattern(object):
"""
The :class:`Pattern` class is the abstract definition of a pattern.
"""
# Make the class dict-less.
__slots__ = ('include',)
def __init__(self, include):
"""
Initializes the :class:`Pattern` instance.
*include* (:class:`bool` or :data:`None`) is whether the matched
files should be included (:data:`True`), excluded (:data:`False`),
or is a null-operation (:data:`None`).
"""
self.include = include
"""
*include* (:class:`bool` or :data:`None`) is whether the matched
files should be included (:data:`True`), excluded (:data:`False`),
or is a null-operation (:data:`None`).
"""
def match(self, files):
"""
Matches this pattern against the specified files.
*files* (:class:`~collections.abc.Iterable` of :class:`str`) contains
each file relative to the root directory (e.g., ``"relative/path/to/file"``).
Returns an :class:`~collections.abc.Iterable` yielding each matched
file path (:class:`str`).
"""
raise NotImplementedError("{}.{} must override match().".format(self.__class__.__module__, self.__class__.__name__))
class RegexPattern(Pattern):
"""
The :class:`RegexPattern` class is an implementation of a pattern
using regular expressions.
"""
# Make the class dict-less.
__slots__ = ('regex',)
def __init__(self, pattern, include=None):
"""
Initializes the :class:`RegexPattern` instance.
*pattern* (:class:`unicode`, :class:`bytes`, :class:`re.RegexObject`,
or :data:`None`) is the pattern to compile into a regular
expression.
*include* (:class:`bool` or :data:`None`) must be :data:`None`
unless *pattern* is a precompiled regular expression (:class:`re.RegexObject`)
in which case it is whether matched files should be included
(:data:`True`), excluded (:data:`False`), or is a null operation
(:data:`None`).
.. NOTE:: Subclasses do not need to support the *include*
parameter.
"""
self.regex = None
"""
*regex* (:class:`re.RegexObject`) is the regular expression for the
pattern.
"""
if isinstance(pattern, (unicode, bytes)):
assert include is None, "include:{!r} must be null when pattern:{!r} is a string.".format(include, pattern)
regex, include = self.pattern_to_regex(pattern)
# NOTE: Make sure to allow a null regular expression to be
# returned for a null-operation.
if include is not None:
regex = re.compile(regex)
elif pattern is not None and hasattr(pattern, 'match'):
# Assume pattern is a precompiled regular expression.
# - NOTE: Used specified *include*.
regex = pattern
elif pattern is None:
# NOTE: Make sure to allow a null pattern to be passed for a
# null-operation.
assert include is None, "include:{!r} must be null when pattern:{!r} is null.".format(include, pattern)
else:
raise TypeError("pattern:{!r} is not a string, RegexObject, or None.".format(pattern))
super(RegexPattern, self).__init__(include)
self.regex = regex
def __eq__(self, other):
"""
Tests the equality of this regex pattern with *other* (:class:`RegexPattern`)
by comparing their :attr:`~Pattern.include` and :attr:`~RegexPattern.regex`
attributes.
"""
if isinstance(other, RegexPattern):
return self.include == other.include and self.regex == other.regex
else:
return NotImplemented
def match(self, files):
"""
Matches this pattern against the specified files.
*files* (:class:`~collections.abc.Iterable` of :class:`str`)
contains each file relative to the root directory (e.g., "relative/path/to/file").
Returns an :class:`~collections.abc.Iterable` yielding each matched
file path (:class:`str`).
"""
if self.include is not None:
for path in files:
if self.regex.match(path) is not None:
yield path
@classmethod
def pattern_to_regex(cls, pattern):
"""
Convert the pattern into an uncompiled regular expression.
*pattern* (:class:`str`) is the pattern to convert into a regular
expression.
Returns the uncompiled regular expression (:class:`str` or :data:`None`),
and whether matched files should be included (:data:`True`),
excluded (:data:`False`), or is a null-operation (:data:`None`).
.. NOTE:: The default implementation simply returns *pattern* and
:data:`True`.
"""
return pattern, True
@@ -0,0 +1,8 @@
# encoding: utf-8
"""
The *pathspec.patterns* package contains the pattern matching
implementations.
"""
# Load pattern implementations.
from .gitwildmatch import GitWildMatchPattern
@@ -0,0 +1,330 @@
# encoding: utf-8
"""
This module implements Git's wildmatch pattern matching which itself is
derived from Rsync's wildmatch. Git uses wildmatch for its ".gitignore"
files.
"""
from __future__ import unicode_literals
import re
import warnings
from .. import util
from ..compat import unicode
from ..pattern import RegexPattern
#: The encoding to use when parsing a byte string pattern.
_BYTES_ENCODING = 'latin1'
class GitWildMatchPattern(RegexPattern):
"""
The :class:`GitWildMatchPattern` class represents a compiled Git
wildmatch pattern.
"""
# Keep the dict-less class hierarchy.
__slots__ = ()
@classmethod
def pattern_to_regex(cls, pattern):
"""
Convert the pattern into a regular expression.
*pattern* (:class:`unicode` or :class:`bytes`) is the pattern to
convert into a regular expression.
Returns the uncompiled regular expression (:class:`unicode`, :class:`bytes`,
or :data:`None`), and whether matched files should be included
(:data:`True`), excluded (:data:`False`), or if it is a
null-operation (:data:`None`).
"""
if isinstance(pattern, unicode):
return_type = unicode
elif isinstance(pattern, bytes):
return_type = bytes
pattern = pattern.decode(_BYTES_ENCODING)
else:
raise TypeError("pattern:{!r} is not a unicode or byte string.".format(pattern))
pattern = pattern.strip()
if pattern.startswith('#'):
# A pattern starting with a hash ('#') serves as a comment
# (neither includes nor excludes files). Escape the hash with a
# back-slash to match a literal hash (i.e., '\#').
regex = None
include = None
elif pattern == '/':
# EDGE CASE: According to `git check-ignore` (v2.4.1), a single
# '/' does not match any file.
regex = None
include = None
elif pattern:
if pattern.startswith('!'):
# A pattern starting with an exclamation mark ('!') negates the
# pattern (exclude instead of include). Escape the exclamation
# mark with a back-slash to match a literal exclamation mark
# (i.e., '\!').
include = False
# Remove leading exclamation mark.
pattern = pattern[1:]
else:
include = True
if pattern.startswith('\\'):
# Remove leading back-slash escape for escaped hash ('#') or
# exclamation mark ('!').
pattern = pattern[1:]
# Split pattern into segments.
pattern_segs = pattern.split('/')
# Normalize pattern to make processing easier.
if not pattern_segs[0]:
# A pattern beginning with a slash ('/') will only match paths
# directly on the root directory instead of any descendant
# paths. So, remove empty first segment to make pattern relative
# to root.
del pattern_segs[0]
elif len(pattern_segs) == 1 or (len(pattern_segs) == 2 and not pattern_segs[1]):
# A single pattern without a beginning slash ('/') will match
# any descendant path. This is equivalent to "**/{pattern}". So,
# prepend with double-asterisks to make pattern relative to
# root.
# EDGE CASE: This also holds for a single pattern with a
# trailing slash (e.g. dir/).
if pattern_segs[0] != '**':
pattern_segs.insert(0, '**')
else:
# EDGE CASE: A pattern without a beginning slash ('/') but
# contains at least one prepended directory (e.g.
# "dir/{pattern}") should not match "**/dir/{pattern}",
# according to `git check-ignore` (v2.4.1).
pass
if not pattern_segs[-1] and len(pattern_segs) > 1:
# A pattern ending with a slash ('/') will match all descendant
# paths if it is a directory but not if it is a regular file.
# This is equivilent to "{pattern}/**". So, set last segment to
# double asterisks to include all descendants.
pattern_segs[-1] = '**'
# Build regular expression from pattern.
output = ['^']
need_slash = False
end = len(pattern_segs) - 1
for i, seg in enumerate(pattern_segs):
if seg == '**':
if i == 0 and i == end:
# A pattern consisting solely of double-asterisks ('**')
# will match every path.
output.append('.+')
elif i == 0:
# A normalized pattern beginning with double-asterisks
# ('**') will match any leading path segments.
output.append('(?:.+/)?')
need_slash = False
elif i == end:
# A normalized pattern ending with double-asterisks ('**')
# will match any trailing path segments.
output.append('/.*')
else:
# A pattern with inner double-asterisks ('**') will match
# multiple (or zero) inner path segments.
output.append('(?:/.+)?')
need_slash = True
elif seg == '*':
# Match single path segment.
if need_slash:
output.append('/')
output.append('[^/]+')
need_slash = True
else:
# Match segment glob pattern.
if need_slash:
output.append('/')
output.append(cls._translate_segment_glob(seg))
if i == end and include is True:
# A pattern ending without a slash ('/') will match a file
# or a directory (with paths underneath it). E.g., "foo"
# matches "foo", "foo/bar", "foo/bar/baz", etc.
# EDGE CASE: However, this does not hold for exclusion cases
# according to `git check-ignore` (v2.4.1).
output.append('(?:/.*)?')
need_slash = True
output.append('$')
regex = ''.join(output)
else:
# A blank pattern is a null-operation (neither includes nor
# excludes files).
regex = None
include = None
if regex is not None and return_type is bytes:
regex = regex.encode(_BYTES_ENCODING)
return regex, include
@staticmethod
def _translate_segment_glob(pattern):
"""
Translates the glob pattern to a regular expression. This is used in
the constructor to translate a path segment glob pattern to its
corresponding regular expression.
*pattern* (:class:`str`) is the glob pattern.
Returns the regular expression (:class:`str`).
"""
# NOTE: This is derived from `fnmatch.translate()` and is similar to
# the POSIX function `fnmatch()` with the `FNM_PATHNAME` flag set.
escape = False
regex = ''
i, end = 0, len(pattern)
while i < end:
# Get next character.
char = pattern[i]
i += 1
if escape:
# Escape the character.
escape = False
regex += re.escape(char)
elif char == '\\':
# Escape character, escape next character.
escape = True
elif char == '*':
# Multi-character wildcard. Match any string (except slashes),
# including an empty string.
regex += '[^/]*'
elif char == '?':
# Single-character wildcard. Match any single character (except
# a slash).
regex += '[^/]'
elif char == '[':
# Braket expression wildcard. Except for the beginning
# exclamation mark, the whole braket expression can be used
# directly as regex but we have to find where the expression
# ends.
# - "[][!]" matchs ']', '[' and '!'.
# - "[]-]" matchs ']' and '-'.
# - "[!]a-]" matchs any character except ']', 'a' and '-'.
j = i
# Pass brack expression negation.
if j < end and pattern[j] == '!':
j += 1
# Pass first closing braket if it is at the beginning of the
# expression.
if j < end and pattern[j] == ']':
j += 1
# Find closing braket. Stop once we reach the end or find it.
while j < end and pattern[j] != ']':
j += 1
if j < end:
# Found end of braket expression. Increment j to be one past
# the closing braket:
#
# [...]
# ^ ^
# i j
#
j += 1
expr = '['
if pattern[i] == '!':
# Braket expression needs to be negated.
expr += '^'
i += 1
elif pattern[i] == '^':
# POSIX declares that the regex braket expression negation
# "[^...]" is undefined in a glob pattern. Python's
# `fnmatch.translate()` escapes the caret ('^') as a
# literal. To maintain consistency with undefined behavior,
# I am escaping the '^' as well.
expr += '\\^'
i += 1
# Build regex braket expression. Escape slashes so they are
# treated as literal slashes by regex as defined by POSIX.
expr += pattern[i:j].replace('\\', '\\\\')
# Add regex braket expression to regex result.
regex += expr
# Set i to one past the closing braket.
i = j
else:
# Failed to find closing braket, treat opening braket as a
# braket literal instead of as an expression.
regex += '\\['
else:
# Regular character, escape it for regex.
regex += re.escape(char)
return regex
@staticmethod
def escape(s):
"""
Escape special characters in the given string.
*s* (:class:`unicode` or :class:`bytes`) a filename or a string
that you want to escape, usually before adding it to a `.gitignore`
Returns the escaped string (:class:`unicode`, :class:`bytes`)
"""
# Reference: https://git-scm.com/docs/gitignore#_pattern_format
meta_characters = r"[]!*#?"
return "".join("\\" + x if x in meta_characters else x for x in s)
util.register_pattern('gitwildmatch', GitWildMatchPattern)
class GitIgnorePattern(GitWildMatchPattern):
"""
The :class:`GitIgnorePattern` class is deprecated by :class:`GitWildMatchPattern`.
This class only exists to maintain compatibility with v0.4.
"""
def __init__(self, *args, **kw):
"""
Warn about deprecation.
"""
self._deprecated()
return super(GitIgnorePattern, self).__init__(*args, **kw)
@staticmethod
def _deprecated():
"""
Warn about deprecation.
"""
warnings.warn("GitIgnorePattern ('gitignore') is deprecated. Use GitWildMatchPattern ('gitwildmatch') instead.", DeprecationWarning, stacklevel=3)
@classmethod
def pattern_to_regex(cls, *args, **kw):
"""
Warn about deprecation.
"""
cls._deprecated()
return super(GitIgnorePattern, cls).pattern_to_regex(*args, **kw)
# Register `GitIgnorePattern` as "gitignore" for backward compatibility
# with v0.4.
util.register_pattern('gitignore', GitIgnorePattern)
+600
View File
@@ -0,0 +1,600 @@
# encoding: utf-8
"""
This module provides utility methods for dealing with path-specs.
"""
import os
import os.path
import posixpath
import stat
from .compat import Collection, Iterable, string_types, unicode
NORMALIZE_PATH_SEPS = [sep for sep in [os.sep, os.altsep] if sep and sep != posixpath.sep]
"""
*NORMALIZE_PATH_SEPS* (:class:`list` of :class:`str`) contains the path
separators that need to be normalized to the POSIX separator for the
current operating system. The separators are determined by examining
:data:`os.sep` and :data:`os.altsep`.
"""
_registered_patterns = {}
"""
*_registered_patterns* (:class:`dict`) maps a name (:class:`str`) to the
registered pattern factory (:class:`~collections.abc.Callable`).
"""
def detailed_match_files(patterns, files, all_matches=None):
"""
Matches the files to the patterns, and returns which patterns matched
the files.
*patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`)
contains the patterns to use.
*files* (:class:`~collections.abc.Iterable` of :class:`str`) contains
the normalized file paths to be matched against *patterns*.
*all_matches* (:class:`boot` or :data:`None`) is whether to return all
matches patterns (:data:`True`), or only the last matched pattern
(:data:`False`). Default is :data:`None` for :data:`False`.
Returns the matched files (:class:`dict`) which maps each matched file
(:class:`str`) to the patterns that matched in order (:class:`.MatchDetail`).
"""
all_files = files if isinstance(files, Collection) else list(files)
return_files = {}
for pattern in patterns:
if pattern.include is not None:
result_files = pattern.match(all_files)
if pattern.include:
# Add files and record pattern.
for result_file in result_files:
if result_file in return_files:
if all_matches:
return_files[result_file].patterns.append(pattern)
else:
return_files[result_file].patterns[0] = pattern
else:
return_files[result_file] = MatchDetail([pattern])
else:
# Remove files.
for file in result_files:
del return_files[file]
return return_files
def _is_iterable(value):
"""
Check whether the value is an iterable (excludes strings).
*value* is the value to check,
Returns whether *value* is a iterable (:class:`bool`).
"""
return isinstance(value, Iterable) and not isinstance(value, (unicode, bytes))
def iter_tree_entries(root, on_error=None, follow_links=None):
"""
Walks the specified directory for all files and directories.
*root* (:class:`str`) is the root directory to search.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. It will be
called with the exception (:exc:`OSError`). Reraise the exception to
abort the walk. Default is :data:`None` to ignore file-system
exceptions.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. Default is
:data:`None` for :data:`True`.
Raises :exc:`RecursionError` if recursion is detected.
Returns an :class:`~collections.abc.Iterable` yielding each file or
directory entry (:class:`.TreeEntry`) relative to *root*.
"""
if on_error is not None and not callable(on_error):
raise TypeError("on_error:{!r} is not callable.".format(on_error))
if follow_links is None:
follow_links = True
for entry in _iter_tree_entries_next(os.path.abspath(root), '', {}, on_error, follow_links):
yield entry
def iter_tree_files(root, on_error=None, follow_links=None):
"""
Walks the specified directory for all files.
*root* (:class:`str`) is the root directory to search for files.
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions. It will be
called with the exception (:exc:`OSError`). Reraise the exception to
abort the walk. Default is :data:`None` to ignore file-system
exceptions.
*follow_links* (:class:`bool` or :data:`None`) optionally is whether
to walk symbolic links that resolve to directories. Default is
:data:`None` for :data:`True`.
Raises :exc:`RecursionError` if recursion is detected.
Returns an :class:`~collections.abc.Iterable` yielding the path to
each file (:class:`str`) relative to *root*.
"""
if on_error is not None and not callable(on_error):
raise TypeError("on_error:{!r} is not callable.".format(on_error))
if follow_links is None:
follow_links = True
for entry in _iter_tree_entries_next(os.path.abspath(root), '', {}, on_error, follow_links):
if not entry.is_dir(follow_links):
yield entry.path
# Alias `iter_tree_files()` as `iter_tree()`.
iter_tree = iter_tree_files
def _iter_tree_entries_next(root_full, dir_rel, memo, on_error, follow_links):
"""
Scan the directory for all descendant files.
*root_full* (:class:`str`) the absolute path to the root directory.
*dir_rel* (:class:`str`) the path to the directory to scan relative to
*root_full*.
*memo* (:class:`dict`) keeps track of ancestor directories
encountered. Maps each ancestor real path (:class:`str`) to relative
path (:class:`str`).
*on_error* (:class:`~collections.abc.Callable` or :data:`None`)
optionally is the error handler for file-system exceptions.
*follow_links* (:class:`bool`) is whether to walk symbolic links that
resolve to directories.
Yields each entry (:class:`.TreeEntry`).
"""
dir_full = os.path.join(root_full, dir_rel)
dir_real = os.path.realpath(dir_full)
# Remember each encountered ancestor directory and its canonical
# (real) path. If a canonical path is encountered more than once,
# recursion has occurred.
if dir_real not in memo:
memo[dir_real] = dir_rel
else:
raise RecursionError(real_path=dir_real, first_path=memo[dir_real], second_path=dir_rel)
for node_name in os.listdir(dir_full):
node_rel = os.path.join(dir_rel, node_name)
node_full = os.path.join(root_full, node_rel)
# Inspect child node.
try:
node_lstat = os.lstat(node_full)
except OSError as e:
if on_error is not None:
on_error(e)
continue
if stat.S_ISLNK(node_lstat.st_mode):
# Child node is a link, inspect the target node.
is_link = True
try:
node_stat = os.stat(node_full)
except OSError as e:
if on_error is not None:
on_error(e)
continue
else:
is_link = False
node_stat = node_lstat
if stat.S_ISDIR(node_stat.st_mode) and (follow_links or not is_link):
# Child node is a directory, recurse into it and yield its
# descendant files.
yield TreeEntry(node_name, node_rel, node_lstat, node_stat)
for entry in _iter_tree_entries_next(root_full, node_rel, memo, on_error, follow_links):
yield entry
elif stat.S_ISREG(node_stat.st_mode) or is_link:
# Child node is either a file or an unfollowed link, yield it.
yield TreeEntry(node_name, node_rel, node_lstat, node_stat)
# NOTE: Make sure to remove the canonical (real) path of the directory
# from the ancestors memo once we are done with it. This allows the
# same directory to appear multiple times. If this is not done, the
# second occurrence of the directory will be incorrectly interpreted
# as a recursion. See <https://github.com/cpburnz/python-path-specification/pull/7>.
del memo[dir_real]
def lookup_pattern(name):
"""
Lookups a registered pattern factory by name.
*name* (:class:`str`) is the name of the pattern factory.
Returns the registered pattern factory (:class:`~collections.abc.Callable`).
If no pattern factory is registered, raises :exc:`KeyError`.
"""
return _registered_patterns[name]
def match_file(patterns, file):
"""
Matches the file to the patterns.
*patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`)
contains the patterns to use.
*file* (:class:`str`) is the normalized file path to be matched
against *patterns*.
Returns :data:`True` if *file* matched; otherwise, :data:`False`.
"""
matched = False
for pattern in patterns:
if pattern.include is not None:
if file in pattern.match((file,)):
matched = pattern.include
return matched
def match_files(patterns, files):
"""
Matches the files to the patterns.
*patterns* (:class:`~collections.abc.Iterable` of :class:`~pathspec.pattern.Pattern`)
contains the patterns to use.
*files* (:class:`~collections.abc.Iterable` of :class:`str`) contains
the normalized file paths to be matched against *patterns*.
Returns the matched files (:class:`set` of :class:`str`).
"""
all_files = files if isinstance(files, Collection) else list(files)
return_files = set()
for pattern in patterns:
if pattern.include is not None:
result_files = pattern.match(all_files)
if pattern.include:
return_files.update(result_files)
else:
return_files.difference_update(result_files)
return return_files
def _normalize_entries(entries, separators=None):
"""
Normalizes the entry paths to use the POSIX path separator.
*entries* (:class:`~collections.abc.Iterable` of :class:`.TreeEntry`)
contains the entries to be normalized.
*separators* (:class:`~collections.abc.Collection` of :class:`str`; or
:data:`None`) optionally contains the path separators to normalize.
See :func:`normalize_file` for more information.
Returns a :class:`dict` mapping the each normalized file path (:class:`str`)
to the entry (:class:`.TreeEntry`)
"""
norm_files = {}
for entry in entries:
norm_files[normalize_file(entry.path, separators=separators)] = entry
return norm_files
def normalize_file(file, separators=None):
"""
Normalizes the file path to use the POSIX path separator (i.e., ``'/'``).
*file* (:class:`str` or :class:`pathlib.PurePath`) is the file path.
*separators* (:class:`~collections.abc.Collection` of :class:`str`; or
:data:`None`) optionally contains the path separators to normalize.
This does not need to include the POSIX path separator (``'/'``), but
including it will not affect the results. Default is :data:`None` for
:data:`NORMALIZE_PATH_SEPS`. To prevent normalization, pass an empty
container (e.g., an empty tuple ``()``).
Returns the normalized file path (:class:`str`).
"""
# Normalize path separators.
if separators is None:
separators = NORMALIZE_PATH_SEPS
# Convert path object to string.
norm_file = str(file)
for sep in separators:
norm_file = norm_file.replace(sep, posixpath.sep)
# Remove current directory prefix.
if norm_file.startswith('./'):
norm_file = norm_file[2:]
return norm_file
def normalize_files(files, separators=None):
"""
Normalizes the file paths to use the POSIX path separator.
*files* (:class:`~collections.abc.Iterable` of :class:`str` or
:class:`pathlib.PurePath`) contains the file paths to be normalized.
*separators* (:class:`~collections.abc.Collection` of :class:`str`; or
:data:`None`) optionally contains the path separators to normalize.
See :func:`normalize_file` for more information.
Returns a :class:`dict` mapping the each normalized file path (:class:`str`)
to the original file path (:class:`str`)
"""
norm_files = {}
for path in files:
norm_files[normalize_file(path, separators=separators)] = path
return norm_files
def register_pattern(name, pattern_factory, override=None):
"""
Registers the specified pattern factory.
*name* (:class:`str`) is the name to register the pattern factory
under.
*pattern_factory* (:class:`~collections.abc.Callable`) is used to
compile patterns. It must accept an uncompiled pattern (:class:`str`)
and return the compiled pattern (:class:`.Pattern`).
*override* (:class:`bool` or :data:`None`) optionally is whether to
allow overriding an already registered pattern under the same name
(:data:`True`), instead of raising an :exc:`AlreadyRegisteredError`
(:data:`False`). Default is :data:`None` for :data:`False`.
"""
if not isinstance(name, string_types):
raise TypeError("name:{!r} is not a string.".format(name))
if not callable(pattern_factory):
raise TypeError("pattern_factory:{!r} is not callable.".format(pattern_factory))
if name in _registered_patterns and not override:
raise AlreadyRegisteredError(name, _registered_patterns[name])
_registered_patterns[name] = pattern_factory
class AlreadyRegisteredError(Exception):
"""
The :exc:`AlreadyRegisteredError` exception is raised when a pattern
factory is registered under a name already in use.
"""
def __init__(self, name, pattern_factory):
"""
Initializes the :exc:`AlreadyRegisteredError` instance.
*name* (:class:`str`) is the name of the registered pattern.
*pattern_factory* (:class:`~collections.abc.Callable`) is the
registered pattern factory.
"""
super(AlreadyRegisteredError, self).__init__(name, pattern_factory)
@property
def message(self):
"""
*message* (:class:`str`) is the error message.
"""
return "{name!r} is already registered for pattern factory:{pattern_factory!r}.".format(
name=self.name,
pattern_factory=self.pattern_factory,
)
@property
def name(self):
"""
*name* (:class:`str`) is the name of the registered pattern.
"""
return self.args[0]
@property
def pattern_factory(self):
"""
*pattern_factory* (:class:`~collections.abc.Callable`) is the
registered pattern factory.
"""
return self.args[1]
class RecursionError(Exception):
"""
The :exc:`RecursionError` exception is raised when recursion is
detected.
"""
def __init__(self, real_path, first_path, second_path):
"""
Initializes the :exc:`RecursionError` instance.
*real_path* (:class:`str`) is the real path that recursion was
encountered on.
*first_path* (:class:`str`) is the first path encountered for
*real_path*.
*second_path* (:class:`str`) is the second path encountered for
*real_path*.
"""
super(RecursionError, self).__init__(real_path, first_path, second_path)
@property
def first_path(self):
"""
*first_path* (:class:`str`) is the first path encountered for
:attr:`self.real_path <RecursionError.real_path>`.
"""
return self.args[1]
@property
def message(self):
"""
*message* (:class:`str`) is the error message.
"""
return "Real path {real!r} was encountered at {first!r} and then {second!r}.".format(
real=self.real_path,
first=self.first_path,
second=self.second_path,
)
@property
def real_path(self):
"""
*real_path* (:class:`str`) is the real path that recursion was
encountered on.
"""
return self.args[0]
@property
def second_path(self):
"""
*second_path* (:class:`str`) is the second path encountered for
:attr:`self.real_path <RecursionError.real_path>`.
"""
return self.args[2]
class MatchDetail(object):
"""
The :class:`.MatchDetail` class contains information about
"""
#: Make the class dict-less.
__slots__ = ('patterns',)
def __init__(self, patterns):
"""
Initialize the :class:`.MatchDetail` instance.
*patterns* (:class:`~collections.abc.Sequence` of :class:`~pathspec.pattern.Pattern`)
contains the patterns that matched the file in the order they were
encountered.
"""
self.patterns = patterns
"""
*patterns* (:class:`~collections.abc.Sequence` of :class:`~pathspec.pattern.Pattern`)
contains the patterns that matched the file in the order they were
encountered.
"""
class TreeEntry(object):
"""
The :class:`.TreeEntry` class contains information about a file-system
entry.
"""
#: Make the class dict-less.
__slots__ = ('_lstat', 'name', 'path', '_stat')
def __init__(self, name, path, lstat, stat):
"""
Initialize the :class:`.TreeEntry` instance.
*name* (:class:`str`) is the base name of the entry.
*path* (:class:`str`) is the relative path of the entry.
*lstat* (:class:`~os.stat_result`) is the stat result of the direct
entry.
*stat* (:class:`~os.stat_result`) is the stat result of the entry,
potentially linked.
"""
self._lstat = lstat
"""
*_lstat* (:class:`~os.stat_result`) is the stat result of the direct
entry.
"""
self.name = name
"""
*name* (:class:`str`) is the base name of the entry.
"""
self.path = path
"""
*path* (:class:`str`) is the path of the entry.
"""
self._stat = stat
"""
*_stat* (:class:`~os.stat_result`) is the stat result of the linked
entry.
"""
def is_dir(self, follow_links=None):
"""
Get whether the entry is a directory.
*follow_links* (:class:`bool` or :data:`None`) is whether to follow
symbolic links. If this is :data:`True`, a symlink to a directory
will result in :data:`True`. Default is :data:`None` for :data:`True`.
Returns whether the entry is a directory (:class:`bool`).
"""
if follow_links is None:
follow_links = True
node_stat = self._stat if follow_links else self._lstat
return stat.S_ISDIR(node_stat.st_mode)
def is_file(self, follow_links=None):
"""
Get whether the entry is a regular file.
*follow_links* (:class:`bool` or :data:`None`) is whether to follow
symbolic links. If this is :data:`True`, a symlink to a regular file
will result in :data:`True`. Default is :data:`None` for :data:`True`.
Returns whether the entry is a regular file (:class:`bool`).
"""
if follow_links is None:
follow_links = True
node_stat = self._stat if follow_links else self._lstat
return stat.S_ISREG(node_stat.st_mode)
def is_symlink(self):
"""
Returns whether the entry is a symbolic link (:class:`bool`).
"""
return stat.S_ISLNK(self._lstat.st_mode)
def stat(self, follow_links=None):
"""
Get the cached stat result for the entry.
*follow_links* (:class:`bool` or :data:`None`) is whether to follow
symbolic links. If this is :data:`True`, the stat result of the
linked file will be returned. Default is :data:`None` for :data:`True`.
Returns that stat result (:class:`~os.stat_result`).
"""
if follow_links is None:
follow_links = True
return self._stat if follow_links else self._lstat
+1
View File
@@ -0,0 +1 @@
from ray._private.thirdparty.pyamdsmi.pyamdsmi import *
+432
View File
@@ -0,0 +1,432 @@
# MIT License
#
# Copyright (c) 2023 Advanced Micro Devices, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Python bindings shim: wraps the amd-smi Python interface with the same
# smi_* function signatures previously provided by the rocm-smi ctypes binding.
import logging
from collections import namedtuple
# Defer amdsmi import so that importing this module never raises ModuleNotFoundError
# on systems without the amdsmi package (non-AMD machines, CI, etc.).
# smi_initialize() will raise ImportError if amdsmi is not installed.
try:
import amdsmi
from amdsmi import (
AmdSmiInitFlags,
AmdSmiMemoryType,
AmdSmiLibraryException,
)
_AMDSMI_AVAILABLE = True
except ImportError:
amdsmi = None
AmdSmiInitFlags = None
AmdSmiMemoryType = None
AmdSmiLibraryException = Exception # so except clauses below don't NameError
_AMDSMI_AVAILABLE = False
# Module-level handle list, indexed by integer device index.
# Populated during smi_initialize(), cleared on smi_shutdown().
_handles = []
# ── Namedtuple used by smi_get_compute_process_info_by_device ────────────────
# Keeps the same attribute interface (.process_id, .vram_usage) that
# gpu_providers.py expects on the returned objects.
ProcessInfo = namedtuple("ProcessInfo", ["process_id", "vram_usage"])
# ── Init / shutdown ───────────────────────────────────────────────────────────
def smi_initialize():
"""Initialize amd-smi and populate the device handle list."""
if not _AMDSMI_AVAILABLE:
raise ImportError(
"amdsmi package is not installed. "
"Install with: pip install amdsmi"
)
global _handles
amdsmi.amdsmi_init(AmdSmiInitFlags.INIT_AMD_GPUS)
_handles = amdsmi.amdsmi_get_processor_handles()
def smi_shutdown():
"""Shut down amd-smi and clear the handle list."""
global _handles
_handles = []
amdsmi.amdsmi_shut_down()
# ── Device enumeration ────────────────────────────────────────────────────────
def smi_get_device_count():
"""Return the number of AMD GPU devices."""
return len(_handles)
# ── Identity ──────────────────────────────────────────────────────────────────
def smi_get_device_id(dev):
"""Return the 16-bit device ID for GPU dev."""
try:
return amdsmi.amdsmi_get_gpu_id(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_id(%d) failed: %s", dev, e)
return -1
def smi_get_device_name(dev):
"""Return the market name string for GPU dev (e.g. 'Instinct MI300X')."""
try:
return amdsmi.amdsmi_get_gpu_asic_info(_handles[dev])["market_name"]
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_name(%d) failed: %s", dev, e)
return ""
def smi_get_device_unique_id(dev):
"""Return the UUID string for GPU dev (e.g. 'GPU-xxxxxxxx-xxxx-...')."""
try:
return amdsmi.amdsmi_get_gpu_device_uuid(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_unique_id(%d) failed: %s", dev, e)
return ""
# ── Utilization ───────────────────────────────────────────────────────────────
def smi_get_device_utilization(dev):
"""Return GPU busy percent (0-100) for GPU dev."""
try:
return amdsmi.amdsmi_get_gpu_busy_percent(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_utilization(%d) failed: %s", dev, e)
return -1
def smi_get_device_memory_busy(dev):
"""Return memory controller busy percent (0-100) for GPU dev."""
try:
activity = amdsmi.amdsmi_get_gpu_activity(_handles[dev])
val = activity.get("umc_activity", "N/A")
return val if val != "N/A" else -1
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_memory_busy(%d) failed: %s", dev, e)
return -1
# ── Memory ────────────────────────────────────────────────────────────────────
def smi_get_device_memory_used(dev, type="VRAM"):
"""Return used VRAM for GPU dev in bytes."""
try:
return amdsmi.amdsmi_get_gpu_memory_usage(_handles[dev], AmdSmiMemoryType.VRAM)
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_memory_used(%d) failed: %s", dev, e)
return -1
def smi_get_device_memory_total(dev, type="VRAM"):
"""Return total VRAM for GPU dev in bytes."""
try:
return amdsmi.amdsmi_get_gpu_memory_total(_handles[dev], AmdSmiMemoryType.VRAM)
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_memory_total(%d) failed: %s", dev, e)
return -1
def smi_get_device_memory_reserved_pages(dev):
"""Return reserved/bad memory pages info for GPU dev, or -1 if unsupported."""
try:
pages = amdsmi.amdsmi_get_gpu_memory_reserved_pages(_handles[dev])
return (len(pages), pages)
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_memory_reserved_pages(%d) failed: %s", dev, e)
return -1
# ── Power ─────────────────────────────────────────────────────────────────────
def smi_get_device_average_power(dev):
"""Return average socket power for GPU dev in watts (float), or -1."""
try:
info = amdsmi.amdsmi_get_power_info(_handles[dev])
val = info["average_socket_power"]
return float(val) if val != "N/A" else -1
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_average_power(%d) failed: %s", dev, e)
return -1
# ── Processes ─────────────────────────────────────────────────────────────────
def smi_get_device_compute_process():
"""Return list of PIDs running compute workloads on any GPU."""
try:
procs = amdsmi.amdsmi_get_gpu_compute_process_info()
return [p["process_id"] for p in procs]
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_compute_process() failed: %s", e)
return []
def smi_get_compute_process_info_by_device(device_id: int, proc_ids: list) -> list:
"""Return ProcessInfo(process_id, vram_usage) for compute processes on GPU device_id.
proc_ids is the compute-only PID list from smi_get_device_compute_process().
amdsmi_get_gpu_process_list returns all processes on the device (including graphics),
so filter to proc_ids to return only compute processes, matching the old API contract.
vram_usage is in bytes to match the contract expected by gpu_providers.py.
"""
try:
compute_pids = set(proc_ids)
entries = amdsmi.amdsmi_get_gpu_process_list(_handles[device_id])
result = []
for e in entries:
if e["pid"] not in compute_pids:
continue
# vram_mem is in bytes in amdsmi_get_gpu_process_list
vram_bytes = e.get("memory_usage", {}).get("vram_mem", 0)
result.append(ProcessInfo(
process_id=e["pid"],
vram_usage=vram_bytes,
))
return result
except AmdSmiLibraryException as e:
logging.debug(
"smi_get_compute_process_info_by_device(%d) failed: %s", device_id, e
)
return []
# ── Driver / kernel version ───────────────────────────────────────────────────
def smi_get_kernel_version():
"""Return the kernel driver version string."""
try:
if not _handles:
return ""
info = amdsmi.amdsmi_get_gpu_driver_info(_handles[0])
return info.get("driver_version", "")
except AmdSmiLibraryException as e:
logging.debug("smi_get_kernel_version() failed: %s", e)
return ""
# ── PCIe ─────────────────────────────────────────────────────────────────────
def smi_get_device_pcie_bandwidth(dev):
"""Return PCIe bandwidth info struct for GPU dev, or -1 if unsupported."""
try:
return amdsmi.amdsmi_get_gpu_pci_bandwidth(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_pcie_bandwidth(%d) failed: %s", dev, e)
return -1
def smi_get_device_pci_id(dev):
"""Return the BDF ID (Bus/Device/Function) for GPU dev as an int."""
try:
return amdsmi.amdsmi_get_gpu_bdf_id(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_pci_id(%d) failed: %s", dev, e)
return -1
def smi_get_device_topo_numa_affinity(dev):
"""Return NUMA affinity for GPU dev."""
try:
return amdsmi.amdsmi_get_gpu_topo_numa_affinity(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_topo_numa_affinity(%d) failed: %s", dev, e)
return -1
def smi_get_device_pcie_throughput(dev):
"""Return PCIe throughput (sent + received) * max_packet_size for GPU dev."""
try:
info = amdsmi.amdsmi_get_gpu_pci_throughput(_handles[dev])
return (info["sent"] + info["received"]) * info["max_pkt_sz"]
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_pcie_throughput(%d) failed: %s", dev, e)
return -1
def smi_get_device_pci_replay_counter(dev):
"""Return PCIe replay counter for GPU dev."""
try:
return amdsmi.amdsmi_get_gpu_pci_replay_counter(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_pci_replay_counter(%d) failed: %s", dev, e)
return -1
# ── Compute partition ─────────────────────────────────────────────────────────
def smi_get_device_compute_partition(dev):
"""Return the current compute partition string for GPU dev."""
try:
return amdsmi.amdsmi_get_gpu_compute_partition(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_compute_partition(%d) failed: %s", dev, e)
return ""
def smi_set_device_compute_partition(dev, partition):
"""Set the compute partition for GPU dev. partition is an AmdSmiComputePartitionType."""
try:
amdsmi.amdsmi_set_gpu_compute_partition(_handles[dev], partition)
return True
except AmdSmiLibraryException as e:
logging.debug("smi_set_device_compute_partition(%d) failed: %s", dev, e)
return False
def smi_reset_device_compute_partition(dev):
"""Reset the compute partition for GPU dev to its boot state."""
try:
amdsmi.amdsmi_set_gpu_accelerator_partition_profile(_handles[dev], 0)
return True
except AmdSmiLibraryException as e:
logging.debug("smi_reset_device_compute_partition(%d) failed: %s", dev, e)
return False
# ── Memory partition ──────────────────────────────────────────────────────────
def smi_get_device_memory_partition(dev):
"""Return the current memory partition string for GPU dev."""
try:
return amdsmi.amdsmi_get_gpu_memory_partition(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_memory_partition(%d) failed: %s", dev, e)
return ""
def smi_set_device_memory_partition(dev, partition):
"""Set the memory partition for GPU dev. partition is an AmdSmiMemoryPartitionType."""
try:
amdsmi.amdsmi_set_gpu_memory_partition(_handles[dev], partition)
return True
except AmdSmiLibraryException as e:
logging.debug("smi_set_device_memory_partition(%d) failed: %s", dev, e)
return False
def smi_reset_device_memory_partition(dev):
"""Reset the memory partition for GPU dev to its boot state."""
try:
amdsmi.amdsmi_set_gpu_memory_partition_mode(_handles[dev], 0)
return True
except AmdSmiLibraryException as e:
logging.debug("smi_reset_device_memory_partition(%d) failed: %s", dev, e)
return False
# ── Hardware topology ─────────────────────────────────────────────────────────
def smi_get_device_topo_numa_node_number(dev):
"""Return the NUMA node number for GPU dev."""
try:
return amdsmi.amdsmi_topo_get_numa_node_number(_handles[dev])
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_topo_numa_node_number(%d) failed: %s", dev, e)
return -1
def smi_get_device_topo_link_weight(dev_src, dev_dst):
"""Return the link weight between two GPUs."""
try:
return amdsmi.amdsmi_topo_get_link_weight(_handles[dev_src], _handles[dev_dst])
except AmdSmiLibraryException as e:
logging.debug(
"smi_get_device_topo_link_weight(%d, %d) failed: %s", dev_src, dev_dst, e
)
return -1
def smi_get_device_minmax_bandwidth(dev_src, dev_dst):
"""Return (min_bandwidth, max_bandwidth) between two GPUs, or -1."""
try:
result = amdsmi.amdsmi_get_minmax_bandwidth_between_processors(
_handles[dev_src], _handles[dev_dst]
)
return (result["min_bandwidth"], result["max_bandwidth"])
except AmdSmiLibraryException as e:
logging.debug(
"smi_get_device_minmax_bandwidth(%d, %d) failed: %s", dev_src, dev_dst, e
)
return -1
def smi_get_device_link_type(dev_src, dev_dst):
"""Return (hops, link_type) between two GPUs, or -1."""
try:
result = amdsmi.amdsmi_topo_get_link_type(_handles[dev_src], _handles[dev_dst])
return (result["hops"], result["type"])
except AmdSmiLibraryException as e:
logging.debug(
"smi_get_device_link_type(%d, %d) failed: %s", dev_src, dev_dst, e
)
return -1
def smi_is_device_p2p_accessible(dev_src, dev_dst):
"""Return True if two GPUs are P2P accessible, False if not, -1 on error."""
try:
return amdsmi.amdsmi_is_P2P_accessible(_handles[dev_src], _handles[dev_dst])
except AmdSmiLibraryException as e:
logging.debug(
"smi_is_device_p2p_accessible(%d, %d) failed: %s", dev_src, dev_dst, e
)
return -1
# ── XGMI ─────────────────────────────────────────────────────────────────────
def smi_get_device_xgmi_error_status(dev):
"""Return the XGMI error status (AmdSmiXgmiStatus int value) for GPU dev."""
try:
status = amdsmi.amdsmi_gpu_xgmi_error_status(_handles[dev])
return status.value
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_xgmi_error_status(%d) failed: %s", dev, e)
return -1
def smi_reset_device_xgmi_error(dev):
"""Reset XGMI error status for GPU dev. Returns True on success."""
try:
amdsmi.amdsmi_reset_gpu_xgmi_error(_handles[dev])
return True
except AmdSmiLibraryException as e:
logging.debug("smi_reset_device_xgmi_error(%d) failed: %s", dev, e)
return False
def smi_get_device_xgmi_hive_id(dev):
"""Return the XGMI hive ID for GPU dev."""
try:
return amdsmi.amdsmi_get_xgmi_info(_handles[dev])["xgmi_hive_id"]
except AmdSmiLibraryException as e:
logging.debug("smi_get_device_xgmi_hive_id(%d) failed: %s", dev, e)
return -1
@@ -0,0 +1,11 @@
load("@rules_python//python:defs.bzl", "py_test")
py_test(
name = "test_pyamdsmi",
size = "small",
srcs = ["test_pyamdsmi.py"],
tags = [
"manual",
"team:core",
],
)
@@ -0,0 +1,381 @@
"""
Integration tests for the pyamdsmi wrapper functions.
Run on a machine with AMD GPU(s):
python3 -m unittest discover \
-s python/ray/_private/thirdparty/pyamdsmi/tests \
-p "test_pyamdsmi.py" -v
"""
import importlib.util
import pathlib
import unittest
_mod_path = pathlib.Path(__file__).parent.parent / "pyamdsmi.py"
_spec = importlib.util.spec_from_file_location("pyamdsmi", _mod_path)
pyamdsmi = importlib.util.module_from_spec(_spec)
try:
_spec.loader.exec_module(pyamdsmi)
_IMPORT_ERROR = None
except Exception as e:
_IMPORT_ERROR = e
def _try_init():
"""Attempt to initialize SMI. Returns (success, device_count)."""
if _IMPORT_ERROR:
return False, 0
try:
pyamdsmi.smi_initialize()
count = pyamdsmi.smi_get_device_count()
if count <= 0:
return False, 0
return True, count
except Exception:
return False, 0
_SMI_OK, _DEVICE_COUNT = _try_init()
_SKIP_REASON = (
f"amdsmi import failed: {_IMPORT_ERROR}" if _IMPORT_ERROR
else "No AMD GPU or amd-smi library available"
)
def skipNoGpu(fn):
return unittest.skipUnless(_SMI_OK, _SKIP_REASON)(fn)
def _fmt(value):
"""Human-readable value or 'not supported' for -1."""
if value == -1:
return "not supported"
return repr(value)
class PyAmdSmiTestBase(unittest.TestCase):
"""Base class that ensures smi_initialize() is called before each test class
and smi_shutdown() after. This prevents test_initialize_and_shutdown from
leaving _handles empty for all subsequent classes."""
@classmethod
def setUpClass(cls):
if _SMI_OK:
pyamdsmi.smi_initialize()
@classmethod
def tearDownClass(cls):
if _SMI_OK:
pyamdsmi.smi_shutdown()
class TestSmiLibraryInit(PyAmdSmiTestBase):
@skipNoGpu
def test_initialize_and_shutdown(self):
"""smi_initialize / smi_shutdown should not raise."""
pyamdsmi.smi_shutdown()
pyamdsmi.smi_initialize() # restore for remaining tests in this class
@skipNoGpu
def test_device_count_is_positive(self):
count = pyamdsmi.smi_get_device_count()
print(f"\n device_count: {count}")
self.assertIsInstance(count, int)
self.assertGreater(count, 0)
@skipNoGpu
def test_kernel_version_is_string(self):
ver = pyamdsmi.smi_get_kernel_version()
print(f"\n kernel_version: {ver!r}")
self.assertIsInstance(ver, str)
class TestSmiPerDeviceIdentity(PyAmdSmiTestBase):
"""Identity fields: id, name, uuid."""
@skipNoGpu
def test_device_id_is_nonnegative_int(self):
for dev in range(_DEVICE_COUNT):
dev_id = pyamdsmi.smi_get_device_id(dev)
print(f"\n dev{dev} device_id: {dev_id:#x}")
with self.subTest(dev=dev):
self.assertIsInstance(dev_id, int)
self.assertGreaterEqual(dev_id, 0)
@skipNoGpu
def test_device_name_is_nonempty_string(self):
for dev in range(_DEVICE_COUNT):
name = pyamdsmi.smi_get_device_name(dev)
print(f"\n dev{dev} name: {name!r}")
with self.subTest(dev=dev):
self.assertIsInstance(name, str)
self.assertGreater(len(name), 0)
@skipNoGpu
def test_device_unique_id_is_nonempty_string(self):
for dev in range(_DEVICE_COUNT):
uid = pyamdsmi.smi_get_device_unique_id(dev)
print(f"\n dev{dev} unique_id: {uid!r}")
with self.subTest(dev=dev):
self.assertIsInstance(uid, str)
self.assertGreater(len(uid), 0)
class TestSmiPerDeviceUtilization(PyAmdSmiTestBase):
@skipNoGpu
def test_gpu_utilization_is_valid_percent(self):
for dev in range(_DEVICE_COUNT):
util = pyamdsmi.smi_get_device_utilization(dev)
print(f"\n dev{dev} gpu_utilization: {util}%")
with self.subTest(dev=dev):
self.assertIsInstance(util, int)
self.assertGreaterEqual(util, 0)
self.assertLessEqual(util, 100)
@skipNoGpu
def test_memory_busy_is_valid_percent_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
busy = pyamdsmi.smi_get_device_memory_busy(dev)
print(f"\n dev{dev} memory_busy: {_fmt(busy)}")
with self.subTest(dev=dev):
self.assertIsInstance(busy, int)
self.assertTrue(
busy == -1 or (0 <= busy <= 100),
f"Expected 0-100 or -1 (not supported), got {busy}"
)
class TestSmiPerDeviceMemory(PyAmdSmiTestBase):
@skipNoGpu
def test_memory_used_is_nonnegative_int(self):
for dev in range(_DEVICE_COUNT):
used = pyamdsmi.smi_get_device_memory_used(dev)
print(f"\n dev{dev} memory_used: {used} bytes ({used // 1024 // 1024} MB)")
with self.subTest(dev=dev):
self.assertIsInstance(used, int)
self.assertGreaterEqual(used, 0)
@skipNoGpu
def test_memory_total_is_positive_int(self):
for dev in range(_DEVICE_COUNT):
total = pyamdsmi.smi_get_device_memory_total(dev)
print(f"\n dev{dev} memory_total: {total} bytes ({total // 1024 // 1024} MB)")
with self.subTest(dev=dev):
self.assertIsInstance(total, int)
self.assertGreater(total, 0)
@skipNoGpu
def test_memory_used_does_not_exceed_total(self):
for dev in range(_DEVICE_COUNT):
used = pyamdsmi.smi_get_device_memory_used(dev)
total = pyamdsmi.smi_get_device_memory_total(dev)
print(f"\n dev{dev} memory_used/total: {used}/{total} bytes")
with self.subTest(dev=dev):
self.assertLessEqual(used, total)
@skipNoGpu
def test_memory_reserved_pages_is_tuple_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
result = pyamdsmi.smi_get_device_memory_reserved_pages(dev)
print(f"\n dev{dev} memory_reserved_pages: {_fmt(result)}")
with self.subTest(dev=dev):
self.assertTrue(
result == -1 or isinstance(result, tuple),
f"Expected tuple or -1, got {type(result)}"
)
class TestSmiPerDevicePower(PyAmdSmiTestBase):
@skipNoGpu
def test_average_power_is_nonnegative_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
power = pyamdsmi.smi_get_device_average_power(dev)
print(f"\n dev{dev} average_power: {_fmt(power)} W")
with self.subTest(dev=dev):
self.assertIsInstance(power, (int, float))
self.assertTrue(
power == -1 or power >= 0,
f"Expected nonneg or -1 (not supported), got {power}"
)
class TestSmiPerDevicePcie(PyAmdSmiTestBase):
@skipNoGpu
def test_pci_id_is_nonnegative_int(self):
for dev in range(_DEVICE_COUNT):
pci_id = pyamdsmi.smi_get_device_pci_id(dev)
print(f"\n dev{dev} pci_id: {pci_id:#x}")
with self.subTest(dev=dev):
self.assertIsInstance(pci_id, int)
self.assertGreaterEqual(pci_id, 0)
@skipNoGpu
def test_pcie_bandwidth_is_struct_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
bw = pyamdsmi.smi_get_device_pcie_bandwidth(dev)
print(f"\n dev{dev} pcie_bandwidth: {_fmt(bw)}")
with self.subTest(dev=dev):
self.assertTrue(
bw == -1 or bw is not None,
f"Expected bandwidth struct or -1, got {bw!r}"
)
@skipNoGpu
def test_pcie_throughput_is_nonnegative_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
result = pyamdsmi.smi_get_device_pcie_throughput(dev)
print(f"\n dev{dev} pcie_throughput: {_fmt(result)}")
with self.subTest(dev=dev):
self.assertTrue(
result == -1 or (isinstance(result, int) and result >= 0),
f"Expected nonneg int or -1, got {result!r}"
)
@skipNoGpu
def test_pci_replay_counter_is_nonnegative_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
counter = pyamdsmi.smi_get_device_pci_replay_counter(dev)
print(f"\n dev{dev} pci_replay_counter: {_fmt(counter)}")
with self.subTest(dev=dev):
self.assertIsInstance(counter, int)
self.assertTrue(
counter == -1 or counter >= 0,
f"Expected nonneg or -1 (not supported), got {counter}"
)
class TestSmiComputeProcess(PyAmdSmiTestBase):
@skipNoGpu
def test_compute_process_list_is_list(self):
procs = pyamdsmi.smi_get_device_compute_process()
print(f"\n compute_processes: {procs}")
self.assertIsInstance(procs, list)
@skipNoGpu
def test_compute_process_pids_are_positive_ints(self):
procs = pyamdsmi.smi_get_device_compute_process()
for pid in procs:
with self.subTest(pid=pid):
self.assertIsInstance(pid, int)
self.assertGreater(pid, 0)
@skipNoGpu
def test_compute_process_info_by_device_is_list(self):
procs = pyamdsmi.smi_get_device_compute_process()
for dev in range(_DEVICE_COUNT):
info = pyamdsmi.smi_get_compute_process_info_by_device(dev, procs)
print(f"\n dev{dev} compute_process_info: {len(info)} entries")
with self.subTest(dev=dev):
self.assertIsInstance(info, list)
@skipNoGpu
def test_compute_process_info_entries_have_process_id(self):
procs = pyamdsmi.smi_get_device_compute_process()
if not procs:
self.skipTest("No active compute processes")
for dev in range(_DEVICE_COUNT):
info = pyamdsmi.smi_get_compute_process_info_by_device(dev, procs)
for entry in info:
pid = entry.process_id
print(f"\n dev{dev} process pid: {pid}")
with self.subTest(dev=dev):
self.assertIsInstance(pid, int)
self.assertGreater(pid, 0)
class TestSmiPartition(PyAmdSmiTestBase):
"""Compute and memory partition queries (MI300 and newer)."""
@skipNoGpu
def test_compute_partition_is_string(self):
for dev in range(_DEVICE_COUNT):
result = pyamdsmi.smi_get_device_compute_partition(dev)
print(f"\n dev{dev} compute_partition: {result!r}")
with self.subTest(dev=dev):
self.assertIsInstance(result, str)
@skipNoGpu
def test_memory_partition_is_string(self):
for dev in range(_DEVICE_COUNT):
result = pyamdsmi.smi_get_device_memory_partition(dev)
print(f"\n dev{dev} memory_partition: {result!r}")
with self.subTest(dev=dev):
self.assertIsInstance(result, str)
class TestSmiTopology(PyAmdSmiTestBase):
@skipNoGpu
def test_numa_node_number_is_nonnegative_int(self):
for dev in range(_DEVICE_COUNT):
numa = pyamdsmi.smi_get_device_topo_numa_node_number(dev)
print(f"\n dev{dev} numa_node: {numa}")
with self.subTest(dev=dev):
self.assertIsInstance(numa, int)
self.assertGreaterEqual(numa, 0)
@skipNoGpu
def test_link_type_between_devices_is_tuple_or_not_supported(self):
if _DEVICE_COUNT < 2:
self.skipTest("Need at least 2 GPUs for topology tests")
result = pyamdsmi.smi_get_device_link_type(0, 1)
print(f"\n dev0->dev1 link_type: {_fmt(result)}")
self.assertTrue(
result == -1 or (isinstance(result, tuple) and len(result) == 2),
f"Expected (hops, type) tuple or -1, got {result!r}"
)
@skipNoGpu
def test_link_weight_between_devices_is_nonnegative_or_not_supported(self):
if _DEVICE_COUNT < 2:
self.skipTest("Need at least 2 GPUs for topology tests")
result = pyamdsmi.smi_get_device_topo_link_weight(0, 1)
print(f"\n dev0->dev1 link_weight: {_fmt(result)}")
self.assertTrue(
result == -1 or (isinstance(result, int) and result >= 0),
f"Expected nonneg int or -1, got {result!r}"
)
@skipNoGpu
def test_p2p_accessible_is_bool_or_not_supported(self):
if _DEVICE_COUNT < 2:
self.skipTest("Need at least 2 GPUs for topology tests")
result = pyamdsmi.smi_is_device_p2p_accessible(0, 1)
print(f"\n dev0->dev1 p2p_accessible: {_fmt(result)}")
self.assertIn(result, [True, False, -1])
class TestSmiXgmi(PyAmdSmiTestBase):
@skipNoGpu
def test_xgmi_error_status_is_nonneg_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
result = pyamdsmi.smi_get_device_xgmi_error_status(dev)
print(f"\n dev{dev} xgmi_error_status: {_fmt(result)}")
with self.subTest(dev=dev):
self.assertTrue(
result == -1 or (isinstance(result, int) and result >= 0),
f"Expected nonneg int or -1, got {result!r}"
)
@skipNoGpu
def test_xgmi_hive_id_is_nonneg_or_not_supported(self):
for dev in range(_DEVICE_COUNT):
result = pyamdsmi.smi_get_device_xgmi_hive_id(dev)
print(f"\n dev{dev} xgmi_hive_id: {_fmt(result)}")
with self.subTest(dev=dev):
self.assertTrue(
result == -1 or (isinstance(result, int) and result >= 0),
f"Expected nonneg int or -1, got {result!r}"
)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -0,0 +1,3 @@
from ray._private.thirdparty.pynvml.pynvml import *
# nvdia-ml-py version
__version__ = "13.580.65"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff