From 78d1cd219e53ccb1a9921bce2395d31ba44e05c7 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Tue, 27 Aug 2024 16:43:49 +0200 Subject: [PATCH 1/9] Automate management of aliases --- .github/workflows/python.yml | 3 + python/aliases.py | 167 ++++++++++++++++++++++++ python/hopsworks/internal/aliases.py | 112 ++++++++++++++++ python/hopsworks/internal/exceptions.py | 25 ++++ 4 files changed, 307 insertions(+) create mode 100755 python/aliases.py create mode 100644 python/hopsworks/internal/aliases.py create mode 100644 python/hopsworks/internal/exceptions.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9ad3513ad..c70c17fba 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -53,6 +53,9 @@ jobs: ${{ steps.get-changed-files.outputs.all_changed_files }} run: ruff format $ALL_CHANGED_FILES + - name: check aliases + run: python python/aliases.py check + unit_tests: name: Unit Tests needs: lint_stylecheck diff --git a/python/aliases.py b/python/aliases.py new file mode 100755 index 000000000..2acdfce9b --- /dev/null +++ b/python/aliases.py @@ -0,0 +1,167 @@ +#!/usr/bin/python + +# +# Copyright 2024 Hopsworks AB +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Scripts for automatic management of aliases.""" + +import importlib +import sys +from pathlib import Path + + +SOURCES = [ + "hopsworks/__init__.py", + "hopsworks/connection.py", + "hopsworks/internal", + "hopsworks/platform", + "hopsworks/fs", + "hopsworks/ml", +] +IGNORED = ["tests", "hsfs", "hopsworks", "hsml", "hopsworks_common"] +# Everything that is not a top-level file, a part of sources, or a part of ignored is considered to be autmoatically managed. + + +def traverse(path, f): + if not path.exists(): + return + if path.is_file(): + f(path) + return + for child in path.iterdir(): + traverse(child, f) + + +def collect_imports(root): + imports = [] + + def imports_add(file): + pkg = str(file.parent.relative_to(root)).replace("/", ".") + if file.name == "__init__.py": + imports.append(pkg) + elif file.name.endswith(".py"): + imports.append(pkg + "." + file.name[:-3]) + + for source in SOURCES: + traverse(root / source, imports_add) + + return imports + + +def collect_aliases(root): + for import_str in collect_imports(root): + importlib.import_module(import_str, package=".") + aliases = importlib.import_module("hopsworks.internal.aliases", package=".") + return aliases._aliases + + +def collect_managed(root): + managed = {} + for pkg, from_imports in collect_aliases(root).items(): + pkg = root / pkg.replace(".", "/") / "__init__.py" + managed[pkg] = ( + "# ruff: noqa\n" + "# This file is generated by aliases.py. Do not edit it manually!\n" + ) + from_imports.sort() # this is needed for determinism + for f, i in from_imports: + managed[pkg] += f"from {f} import {i}\n" + return managed + + +def fix(root): + managed = collect_managed(root) + for filepath, content in managed.items(): + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.touch() + filepath.write_text(content) + ignored = [root / path for path in SOURCES + IGNORED] + + def remove_if_excess(path): + if path.parent == root: + return + if any(path.is_relative_to(p) for p in ignored): + return + if path not in managed: + path.unlink() + + traverse(root, remove_if_excess) + + +def check(root): + global ok + ok = True + managed = collect_managed(root) + ignored = [root / path for path in SOURCES + IGNORED] + + def check_file(path): + global ok + if path.parent == root or any(path.is_relative_to(p) for p in ignored): + return + if path not in managed: + print(f"Error: {path} shouldn't exist.") + ok = False + return + if path.read_text() != managed[path]: + print(f"Error: {path} has wrong content.") + ok = False + + traverse(root, check_file) + + if ok: + print("The aliases are correct!") + else: + print("To fix the errors, run `aliases.py fix`.") + exit(1) + + +def help(msg=None): + if msg: + print(msg + "\n") + print("Use `aliases.py fix [path]` or `aliases.py check [path]`.") + print( + "`path` is optional, current directory (or its `python` subdirectory) is used by default; it should be the directory containing the hopsworks package, e.g., `./python/`." + ) + exit(1) + + +def main(): + if len(sys.argv) == 3: + root = Path(sys.argv[2]) + elif len(sys.argv) == 2: + root = Path() + if not (root / "hopsworks").exists(): + root = root / "python" + else: + help("Wrong number of arguments.") + + root = root.resolve() + if not (root / "hopsworks").exists(): + help("The used path doesn't contain the hopsworks package.") + + cmd = sys.argv[1] + if cmd in ["f", "fix"]: + cmd = fix + elif cmd in ["c", "check"]: + cmd = check + else: + help("Unknown command.") + + cmd(root) + + +if __name__ == "__main__": + main() diff --git a/python/hopsworks/internal/aliases.py b/python/hopsworks/internal/aliases.py new file mode 100644 index 000000000..17d7e9c5d --- /dev/null +++ b/python/hopsworks/internal/aliases.py @@ -0,0 +1,112 @@ +# +# Copyright 2024 Hopsworks AB +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Automatic management of aliases. + +The associated scripts are located in `python/aliases.py`. +""" + +from __future__ import annotations + +import functools +import inspect +import warnings +from typing import Optional, Tuple + +from hopsworks.internal.exceptions import InternalError + + +_aliases = {} + + +def _aliases_add(from_import: Tuple[str, str], *paths: str): + global _aliases + if "." in from_import[1]: + raise InternalError("Impossible to create alias for not importable symbol.") + for p in paths: + _aliases.setdefault(p, []).append(from_import) + + +def public(*paths: str): + """Make a function or class publically available. + + If you want to publish a constant, use `publish`. + Note that it is impossible to create an alias for a variable, i.e., it is impossible to make a change of a variable in one module to propogate to another variable in another module. + + # Arguments + paths: the import paths under which the entity is publically avilable; effectively results in generation of aliases in all of the paths for the entity. + """ + + global publics + + def decorator(symbol): + if not hasattr(symbol, "__qualname__"): + raise InternalError("The symbol should be importable to be public.") + _aliases_add((symbol.__module__, symbol.__qualname__), *paths) + return symbol + + return decorator + + +def publish(name: str, *paths: str): + """Make a constant publically available. + + Since `public` decorator works only for classes and functions, this function should be used for public constants. + Note that it is impossible to create an alias for a variable, i.e., it is impossible to make a change of a variable in one module to propogate to another variable in another module. + + # Arguments + name: name of the constant to be published. + paths: the import paths under which the names declared in the current module will be publically available; effectively results in generation of aliases in all of the paths for all the names declared in the current module. + """ + + caller = inspect.getmodule(inspect.stack()[1][0]) + + _aliases_add((caller.__name__, name), *paths) + + +class DeprecatedCallWarning(Warning): + pass + + +def deprecated(*, available_until: Optional[str] = None): + """Mark a function or class as deprecated. + + Use of the entity outside hopsworks will print a warning, saying that it is going to be removed from the public API in one of the future releases. + + # Arguments + available_until: the first hopsworks release in which the entity will become unavailable, defaults to `None`; if the release is known, it is reoprted to the external user in the warning. + """ + + v = f"version {available_until}" if available_until else "a future release" + + def decorator(symbol): + if inspect.isclass(symbol): + + @functools.wraps(symbol) + def decorated_f(*args, **kwargs): + caller = inspect.getmodule(inspect.stack()[1][0]) + if not caller or not caller.__name__.startswith("hopsworks"): + warnings.warn( + f"Use of {symbol.__qualname__} is deprecated." + f"The function will be removed in {v} of hopsworks.", + DeprecatedCallWarning, + stacklevel=2, + ) + return symbol(*args, **kwargs) + + return decorated_f + + return decorator diff --git a/python/hopsworks/internal/exceptions.py b/python/hopsworks/internal/exceptions.py new file mode 100644 index 000000000..443ba7d14 --- /dev/null +++ b/python/hopsworks/internal/exceptions.py @@ -0,0 +1,25 @@ +# +# Copyright 2024 Hopsworks AB +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +class InternalError(Exception): + """Internal hopsworks exception. + + This is raised in cases when the user of hopsworks cannot be blaimed for the error. + Ideally, this exception should never happen, as it means that one of the hopsworks contributors commited an error. + + For example, this exception can be thrown if an internally called function which works only with `str` was given a `float`, or if a public alias is requested for a method of a class. + """ From 4f330addcc289a1d760a4a6075b41d7c6d2aa6c3 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Tue, 27 Aug 2024 17:17:19 +0200 Subject: [PATCH 2/9] Fix workflow --- .github/workflows/python.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c70c17fba..212a63957 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -53,7 +53,21 @@ jobs: ${{ steps.get-changed-files.outputs.all_changed_files }} run: ruff format $ALL_CHANGED_FILES - - name: check aliases + check_aliases: + name: Check Aliases + needs: lint_stylecheck + runs-on: ubuntu-latest + + steps: + - uses: actions/setup-python@v5 + name: Setup Python + with: + python-version: "3.10" + cache: "pip" + cache-dependency-path: "python/setup.py" + - run: pip install -e python[dev] + + - name: Check aliases run: python python/aliases.py check unit_tests: From a63c8a05b299ceca26d0d221769ab53523f30962 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Tue, 27 Aug 2024 17:19:04 +0200 Subject: [PATCH 3/9] Fix workflow again --- .github/workflows/python.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 212a63957..5d334e19a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -59,6 +59,10 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Copy README + run: cp README.md python/ + - uses: actions/setup-python@v5 name: Setup Python with: From e1b45a3ed2651854fbad9a1cbc06d29b6183713f Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Tue, 27 Aug 2024 17:20:56 +0200 Subject: [PATCH 4/9] From efb2bc50c50b49a019c42010b749d667372133b7 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Tue, 27 Aug 2024 17:24:01 +0200 Subject: [PATCH 5/9] Ignore hopsworks.egg-info --- python/aliases.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/python/aliases.py b/python/aliases.py index 2acdfce9b..925c56d67 100755 --- a/python/aliases.py +++ b/python/aliases.py @@ -31,7 +31,14 @@ "hopsworks/fs", "hopsworks/ml", ] -IGNORED = ["tests", "hsfs", "hopsworks", "hsml", "hopsworks_common"] +IGNORED = [ + "tests", + "hsfs", + "hopsworks", + "hsml", + "hopsworks_common", + "hopsworks.egg-info", +] # Everything that is not a top-level file, a part of sources, or a part of ignored is considered to be autmoatically managed. From 63d8c11174c356751008628fc2acb5980a122046 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Wed, 28 Aug 2024 10:39:50 +0200 Subject: [PATCH 6/9] Complete decorator `deprecated` --- python/hopsworks/internal/aliases.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/python/hopsworks/internal/aliases.py b/python/hopsworks/internal/aliases.py index 17d7e9c5d..9a91f5d82 100644 --- a/python/hopsworks/internal/aliases.py +++ b/python/hopsworks/internal/aliases.py @@ -92,11 +92,15 @@ def deprecated(*, available_until: Optional[str] = None): v = f"version {available_until}" if available_until else "a future release" - def decorator(symbol): + def deprecate(symbol): if inspect.isclass(symbol): + methods = inspect.getmembers(symbol, predicate=inspect.isfunction) + for name, value in methods: + setattr(symbol, name, deprecate(value)) + elif inspect.isfunction(symbol): @functools.wraps(symbol) - def decorated_f(*args, **kwargs): + def deprecated_f(*args, **kwargs): caller = inspect.getmodule(inspect.stack()[1][0]) if not caller or not caller.__name__.startswith("hopsworks"): warnings.warn( @@ -107,6 +111,8 @@ def decorated_f(*args, **kwargs): ) return symbol(*args, **kwargs) - return decorated_f + return deprecated_f + else: + raise InternalError("Deprecation of something else than class or function.") - return decorator + return deprecate From 1cb877f3f761b18df2aa1406a590064e4ab737f4 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Wed, 28 Aug 2024 15:21:30 +0200 Subject: [PATCH 7/9] Add public context Publisher --- python/aliases.py | 31 +++++- python/hopsworks/internal/aliases.py | 139 +++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 25 deletions(-) diff --git a/python/aliases.py b/python/aliases.py index 925c56d67..2d9d4e9ed 100755 --- a/python/aliases.py +++ b/python/aliases.py @@ -72,20 +72,41 @@ def collect_aliases(root): for import_str in collect_imports(root): importlib.import_module(import_str, package=".") aliases = importlib.import_module("hopsworks.internal.aliases", package=".") - return aliases._aliases + return aliases.Registry.get_modules() def collect_managed(root): managed = {} - for pkg, from_imports in collect_aliases(root).items(): + for pkg, imports in collect_aliases(root).items(): pkg = root / pkg.replace(".", "/") / "__init__.py" managed[pkg] = ( "# ruff: noqa\n" "# This file is generated by aliases.py. Do not edit it manually!\n" ) - from_imports.sort() # this is needed for determinism - for f, i in from_imports: - managed[pkg] += f"from {f} import {i}\n" + if not imports: + continue + managed[pkg] += "import hopsworks.internal.aliases\n" + imports.sort() # this is needed for determinism + imported_modules = {"hopsworks.internal.aliases"} + declared_names = set() + for f, i, im in imports: + original = f"{f}.{i}" + alias = im.as_alias if im.as_alias else i + if alias in declared_names: + print( + f"Error: {original} is attempted to be exported as {alias} in {pkg}, " + "but the package already contains this alias." + ) + exit(1) + if f not in imported_modules: + managed[pkg] += f"import {f}\n" + imported_modules.add(f) + if im.deprecated: + available_until = "" + if im.available_until: + available_until = f'available_until="{im.available.until}"' + original = f"hopsworks.internal.aliases.deprecated({available_until})({original})" + managed[pkg] += f"{alias} = {original}\n" return managed diff --git a/python/hopsworks/internal/aliases.py b/python/hopsworks/internal/aliases.py index 9a91f5d82..ddda847cf 100644 --- a/python/hopsworks/internal/aliases.py +++ b/python/hopsworks/internal/aliases.py @@ -24,23 +24,82 @@ import functools import inspect import warnings -from typing import Optional, Tuple +from dataclasses import dataclass, field +from typing import Dict, Optional from hopsworks.internal.exceptions import InternalError -_aliases = {} +@dataclass +class Alias: + from_module: str + import_name: str + in_modules: Dict[str, InModule] = field(default_factory=dict) + + @dataclass + class InModule: + in_module: str + as_alias: Optional[str] = None + deprecated: bool = False + available_until: Optional[str] = None + + def get_id(self): + res = self.in_module + if self.as_alias: + res += f" as {self.as_alias}" + return res + + def update(self, other: Alias.InModule): + self.deprecated |= other.deprecated + if self.available_until: + if self.available_until != other.available_until: + raise InternalError( + "Deprecated alias is declared available until different releases." + ) + else: + self.available_until = other.available_until + + def __post_init__(self): + if "." in self.import_name: + raise InternalError("Impossible to create alias for not importable symbol.") + + def add(self, *in_modules: InModule): + for im in in_modules: + self.in_modules.setdefault(im.get_id(), im).update(im) + + def get_id(self): + return f"{self.from_module}.{self.import_name}" + + def update(self, other: Alias): + for imid, im in other.in_modules.items(): + self.in_modules.setdefault(imid, im).update(im) + + +class Registry: + def __new__(cls): + return Registry + aliases: Dict[str, Alias] = {} -def _aliases_add(from_import: Tuple[str, str], *paths: str): - global _aliases - if "." in from_import[1]: - raise InternalError("Impossible to create alias for not importable symbol.") - for p in paths: - _aliases.setdefault(p, []).append(from_import) + @staticmethod + def get_modules(): + modules = {} + for alias in Registry.aliases.values(): + for im in alias.in_modules.values(): + from_import = alias.from_module, alias.import_name, im + parts = im.in_module.split(".") + for i in range(1, len(parts)): + modules.setdefault(".".join(parts[:i]), []) + modules.setdefault(im.in_module, []).append(from_import) + return modules + @staticmethod + def add(*aliases): + for alias in aliases: + Registry.aliases.setdefault(alias.get_id(), alias).update(alias) -def public(*paths: str): + +def public(*paths: str, as_alias: Optional[str] = None): """Make a function or class publically available. If you want to publish a constant, use `publish`. @@ -48,33 +107,71 @@ def public(*paths: str): # Arguments paths: the import paths under which the entity is publically avilable; effectively results in generation of aliases in all of the paths for the entity. + as_alias: make the alias of the specified name. """ - global publics - def decorator(symbol): if not hasattr(symbol, "__qualname__"): raise InternalError("The symbol should be importable to be public.") - _aliases_add((symbol.__module__, symbol.__qualname__), *paths) + alias = Alias(symbol.__module__, symbol.__qualname__) + alias.add(*(Alias.InModule(p, as_alias) for p in paths)) + Registry.add(alias) return symbol return decorator -def publish(name: str, *paths: str): - """Make a constant publically available. +class Publisher: + """Publish all of the names defined inside this context. - Since `public` decorator works only for classes and functions, this function should be used for public constants. + Since `public` decorator works only for classes and functions, this context should be used for public constants. + It is also useful for bulk publishing. Note that it is impossible to create an alias for a variable, i.e., it is impossible to make a change of a variable in one module to propogate to another variable in another module. # Arguments - name: name of the constant to be published. - paths: the import paths under which the names declared in the current module will be publically available; effectively results in generation of aliases in all of the paths for all the names declared in the current module. + paths: the import paths under which the names declared in this context will be publically available; effectively results in generation of aliases in all of the paths for all the names declared in the context. """ - caller = inspect.getmodule(inspect.stack()[1][0]) + def __init__(self, *paths: str): + self.paths = set(paths) + + def __enter__(self): + caller = inspect.getmodule(inspect.stack()[1][0]) + self.exclude = set(x for x, _ in inspect.getmembers(caller)) + + def __exit__(self, _exc_type, _exc_value, _traceback): + caller = inspect.getmodule(inspect.stack()[1][0]) + for name in set(x for x, _ in inspect.getmembers(caller)) - self.exclude: + alias = Alias(caller.__name__, name) + alias.add(*(Alias.InModule(p) for p in self.paths)) + Registry.add(alias) + + +def deprecated_public( + *paths: str, + available_until: Optional[str] = None, + as_alias: Optional[str] = None, +): + """Make public aliases as a deprecated versions of a class or a function. - _aliases_add((caller.__name__, name), *paths) + Use of the entity outside hopsworks will print a warning, saying that it is going to be removed from the public API in one of the future releases. + See `deprecated` decorator for the implementation of construction of the deprecated objects. + + # Arguments + paths: the import paths under which the entity is publically avilable; effectively results in generation of deprecated aliases in all of the paths for the entity. + available_until: the first hopsworks release in which the entity will become unavailable, defaults to `None`; if the release is known, it is reoprted to the external user in the warning. + as_alias: make the alias of the specified name. + """ + + def decorator(symbol): + if not hasattr(symbol, "__qualname__"): + raise InternalError("The symbol should be importable to be public.") + alias = Alias(symbol.__module__, symbol.__qualname__) + alias.add(*(Alias.InModule(p, as_alias, True, available_until) for p in paths)) + Registry.add(alias) + return symbol + + return decorator class DeprecatedCallWarning(Warning): @@ -82,9 +179,10 @@ class DeprecatedCallWarning(Warning): def deprecated(*, available_until: Optional[str] = None): - """Mark a function or class as deprecated. + """Create a deprecated version of a function or a class. Use of the entity outside hopsworks will print a warning, saying that it is going to be removed from the public API in one of the future releases. + Therefore, do not use it on classes or functions used internally; it is a utility for creation of deprecated aliases. # Arguments available_until: the first hopsworks release in which the entity will become unavailable, defaults to `None`; if the release is known, it is reoprted to the external user in the warning. @@ -97,6 +195,7 @@ def deprecate(symbol): methods = inspect.getmembers(symbol, predicate=inspect.isfunction) for name, value in methods: setattr(symbol, name, deprecate(value)) + return symbol elif inspect.isfunction(symbol): @functools.wraps(symbol) From 5b2d79e39c46689f260e07252148f7001f58bffd Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Wed, 28 Aug 2024 16:04:20 +0200 Subject: [PATCH 8/9] Complete the aliases management --- python/aliases.py | 12 ++- python/hopsworks/internal/aliases.py | 113 ++++++++++++++---------- python/hopsworks/internal/exceptions.py | 25 ------ 3 files changed, 76 insertions(+), 74 deletions(-) delete mode 100644 python/hopsworks/internal/exceptions.py diff --git a/python/aliases.py b/python/aliases.py index 2d9d4e9ed..4a4f8bfcd 100755 --- a/python/aliases.py +++ b/python/aliases.py @@ -133,6 +133,14 @@ def check(root): global ok ok = True managed = collect_managed(root) + for filepath, content in managed.items(): + if not filepath.exists(): + print(f"Error: {filepath} should exist.") + ok = False + continue + if filepath.read_text() != content: + print(f"Error: {filepath} has wrong content.") + ok = False ignored = [root / path for path in SOURCES + IGNORED] def check_file(path): @@ -142,10 +150,6 @@ def check_file(path): if path not in managed: print(f"Error: {path} shouldn't exist.") ok = False - return - if path.read_text() != managed[path]: - print(f"Error: {path} has wrong content.") - ok = False traverse(root, check_file) diff --git a/python/hopsworks/internal/aliases.py b/python/hopsworks/internal/aliases.py index ddda847cf..3a3d305b0 100644 --- a/python/hopsworks/internal/aliases.py +++ b/python/hopsworks/internal/aliases.py @@ -27,7 +27,12 @@ from dataclasses import dataclass, field from typing import Dict, Optional -from hopsworks.internal.exceptions import InternalError + +class InternalAliasError(Exception): + """Internal hopsworks exception related to aliases. + + Ideally, this exception should never happen, as it means misconfiguration of aliases, for example, if a public alias is requested for a method of a class. + """ @dataclass @@ -53,7 +58,7 @@ def update(self, other: Alias.InModule): self.deprecated |= other.deprecated if self.available_until: if self.available_until != other.available_until: - raise InternalError( + raise InternalAliasError( "Deprecated alias is declared available until different releases." ) else: @@ -61,7 +66,9 @@ def update(self, other: Alias.InModule): def __post_init__(self): if "." in self.import_name: - raise InternalError("Impossible to create alias for not importable symbol.") + raise InternalAliasError( + "Impossible to create alias for not importable symbol." + ) def add(self, *in_modules: InModule): for im in in_modules: @@ -80,98 +87,112 @@ def __new__(cls): return Registry aliases: Dict[str, Alias] = {} + modules = {} @staticmethod def get_modules(): - modules = {} + for module, exclude, paths in Registry.modules.values(): + for name in set(x for x, _ in inspect.getmembers(module)) - exclude: + alias = Alias(module.__name__, name) + alias.add(*(Alias.InModule(p) for p in paths)) + Registry.add(alias) + res = {} for alias in Registry.aliases.values(): for im in alias.in_modules.values(): from_import = alias.from_module, alias.import_name, im parts = im.in_module.split(".") for i in range(1, len(parts)): - modules.setdefault(".".join(parts[:i]), []) - modules.setdefault(im.in_module, []).append(from_import) - return modules + res.setdefault(".".join(parts[:i]), []) + res.setdefault(im.in_module, []).append(from_import) + return res @staticmethod def add(*aliases): for alias in aliases: Registry.aliases.setdefault(alias.get_id(), alias).update(alias) + @staticmethod + def add_module(module, exclude, paths): + Registry.modules[module.__name__] = module, exclude, paths + -def public(*paths: str, as_alias: Optional[str] = None): +def public( + *paths: str, + as_alias: Optional[str] = None, + deprecated: bool = False, + available_until: Optional[str] = None, +): """Make a function or class publically available. If you want to publish a constant, use `publish`. - Note that it is impossible to create an alias for a variable, i.e., it is impossible to make a change of a variable in one module to propogate to another variable in another module. # Arguments paths: the import paths under which the entity is publically avilable; effectively results in generation of aliases in all of the paths for the entity. as_alias: make the alias of the specified name. + deprecated: make the alias deprected; use of the entity outside hopsworks will print a warning, saying that it is going to be removed from the public API in one of the future releases. See `deprecated` decorator for the implementation of construction of the deprecated objects. + available_until: the first hopsworks release in which the entity will become unavailable, defaults to `None`; if the release is known, it is reoprted to the external user in the warning and `deprected` becomes set up. """ + if available_until: + deprecated = True + def decorator(symbol): if not hasattr(symbol, "__qualname__"): - raise InternalError("The symbol should be importable to be public.") + raise InternalAliasError("The symbol should be importable to be public.") alias = Alias(symbol.__module__, symbol.__qualname__) - alias.add(*(Alias.InModule(p, as_alias) for p in paths)) + alias.add( + *(Alias.InModule(p, as_alias, deprecated, available_until) for p in paths) + ) Registry.add(alias) return symbol return decorator -class Publisher: - """Publish all of the names defined inside this context. +def publish(*paths: str): + """Publish all of the names defined in this module after this call. Since `public` decorator works only for classes and functions, this context should be used for public constants. It is also useful for bulk publishing. Note that it is impossible to create an alias for a variable, i.e., it is impossible to make a change of a variable in one module to propogate to another variable in another module. + In case you need to publish something from the begining of a module, use `Publisher`. + + If you need to deprecate an alias, use `public` instead. + # Arguments paths: the import paths under which the names declared in this context will be publically available; effectively results in generation of aliases in all of the paths for all the names declared in the context. """ - def __init__(self, *paths: str): - self.paths = set(paths) + caller = inspect.getmodule(inspect.stack()[1][0]) + exclude = set(x for x, _ in inspect.getmembers(caller)) + Registry.add_module(caller, exclude, paths) - def __enter__(self): - caller = inspect.getmodule(inspect.stack()[1][0]) - self.exclude = set(x for x, _ in inspect.getmembers(caller)) - - def __exit__(self, _exc_type, _exc_value, _traceback): - caller = inspect.getmodule(inspect.stack()[1][0]) - for name in set(x for x, _ in inspect.getmembers(caller)) - self.exclude: - alias = Alias(caller.__name__, name) - alias.add(*(Alias.InModule(p) for p in self.paths)) - Registry.add(alias) +class Publisher: + """Publish all of the names defined inside this context. -def deprecated_public( - *paths: str, - available_until: Optional[str] = None, - as_alias: Optional[str] = None, -): - """Make public aliases as a deprecated versions of a class or a function. + This class is intended for bulk publishing of entitities which are to be declared in the begining of a module, so that publish is not usable; in other cases, use publish instead. + Note that it is impossible to create an alias for a variable, i.e., it is impossible to make a change of a variable in one module to propogate to another variable in another module. - Use of the entity outside hopsworks will print a warning, saying that it is going to be removed from the public API in one of the future releases. - See `deprecated` decorator for the implementation of construction of the deprecated objects. + If you need to deprecate an alias, use `public` instead. # Arguments - paths: the import paths under which the entity is publically avilable; effectively results in generation of deprecated aliases in all of the paths for the entity. - available_until: the first hopsworks release in which the entity will become unavailable, defaults to `None`; if the release is known, it is reoprted to the external user in the warning. - as_alias: make the alias of the specified name. + paths: the import paths under which the names declared in this context will be publically available; effectively results in generation of aliases in all of the paths for all the names declared in the context. """ - def decorator(symbol): - if not hasattr(symbol, "__qualname__"): - raise InternalError("The symbol should be importable to be public.") - alias = Alias(symbol.__module__, symbol.__qualname__) - alias.add(*(Alias.InModule(p, as_alias, True, available_until) for p in paths)) - Registry.add(alias) - return symbol + def __init__(self, *paths: str): + self.paths = set(paths) + self.caller = inspect.getmodule(inspect.stack()[1][0]) - return decorator + def __enter__(self): + self.exclude = set(x for x, _ in inspect.getmembers(self.caller)) + + def __exit__(self, _exc_type, _exc_value, _traceback): + for name in set(x for x, _ in inspect.getmembers(self.caller)) - self.exclude: + alias = Alias(self.caller.__name__, name) + alias.add(*(Alias.InModule(p) for p in self.paths)) + Registry.add(alias) class DeprecatedCallWarning(Warning): @@ -212,6 +233,8 @@ def deprecated_f(*args, **kwargs): return deprecated_f else: - raise InternalError("Deprecation of something else than class or function.") + raise InternalAliasError( + "Deprecation of something else than class or function." + ) return deprecate diff --git a/python/hopsworks/internal/exceptions.py b/python/hopsworks/internal/exceptions.py deleted file mode 100644 index 443ba7d14..000000000 --- a/python/hopsworks/internal/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -# -# Copyright 2024 Hopsworks AB -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - - -class InternalError(Exception): - """Internal hopsworks exception. - - This is raised in cases when the user of hopsworks cannot be blaimed for the error. - Ideally, this exception should never happen, as it means that one of the hopsworks contributors commited an error. - - For example, this exception can be thrown if an internally called function which works only with `str` was given a `float`, or if a public alias is requested for a method of a class. - """ From 282b0185809aead86160ecf39511651acf5c2716 Mon Sep 17 00:00:00 2001 From: Aleksey Veresov Date: Wed, 28 Aug 2024 16:24:07 +0200 Subject: [PATCH 9/9] Ignore pycache in aliases checks --- python/aliases.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/aliases.py b/python/aliases.py index 4a4f8bfcd..dbc8f3093 100755 --- a/python/aliases.py +++ b/python/aliases.py @@ -59,7 +59,7 @@ def imports_add(file): pkg = str(file.parent.relative_to(root)).replace("/", ".") if file.name == "__init__.py": imports.append(pkg) - elif file.name.endswith(".py"): + elif file.suffix == ".py": imports.append(pkg + "." + file.name[:-3]) for source in SOURCES: @@ -147,6 +147,8 @@ def check_file(path): global ok if path.parent == root or any(path.is_relative_to(p) for p in ignored): return + if path.suffix == ".pyc" or "__pycache__" in path.parts: + return if path not in managed: print(f"Error: {path} shouldn't exist.") ok = False