Skip to content

Commit

Permalink
Merge pull request #25 from kurtmckee/support-importlib-resources
Browse files Browse the repository at this point in the history
Partially support reading resources
  • Loading branch information
kurtmckee authored Nov 22, 2024
2 parents 75792d1 + eb3dba6 commit 3f51490
Show file tree
Hide file tree
Showing 31 changed files with 372 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Added
-----

* Partially support accessing resources in packages.
42 changes: 42 additions & 0 deletions src/sqliteimport/accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,45 @@ def get_file(self, path_like: str) -> str:
(path_like,),
).fetchone()[0]
return source

def list_directory(self, path_like: str) -> list[str]:
"""List the contents of a directory."""

base_name = str(pathlib.PurePosixPath(path_like)).replace("/", ".")
sql = """
SELECT
path
FROM code
WHERE
path LIKE $package_like
AND path NOT LIKE $subpackage_like
UNION
SELECT
DISTINCT substr(
path,
0,
length($package) + instr(substr(path, length($package) + 1), '/')
)
FROM code
WHERE
path LIKE $subpackage_like
;
"""

results = self.connection.execute(
sql,
{
"package": f"{base_name}/",
"package_like": f"{base_name}/%",
"subpackage_like": f"{base_name}/%/%",
},
).fetchall()
parsed_results: list[str] = []
for result in results:
if result[0].endswith("/__init__.py"):
parsed_results.append(result[0].rpartition("/")[0])
else:
parsed_results.append(result[0])
return parsed_results
116 changes: 111 additions & 5 deletions src/sqliteimport/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
import importlib.abc
import importlib.machinery
import importlib.metadata
import io
import os.path
import pathlib
import sqlite3
import sys
import types
import typing

if sys.version_info >= (3, 11):
from importlib.resources.abc import Traversable, TraversableResources
else:
from importlib.abc import Traversable, TraversableResources

from sqliteimport.accessor import Accessor


Expand Down Expand Up @@ -42,7 +48,7 @@ def find_spec(
source, is_package = result
spec = importlib.machinery.ModuleSpec(
name=fullname,
loader=SqliteLoader(source),
loader=SqliteLoader(source, self.accessor),
origin=self.database.name,
is_package=is_package,
)
Expand All @@ -59,15 +65,16 @@ def find_distributions(


class SqliteLoader(importlib.abc.Loader):
def __init__(self, source: str) -> None:
def __init__(self, source: str, accessor: Accessor) -> None:
self.source = source

def create_module(self, spec: importlib.machinery.ModuleSpec) -> None:
return None
self.accessor = accessor

def exec_module(self, module: types.ModuleType) -> None:
exec(self.source, module.__dict__)

def get_resource_reader(self, fullname: str) -> SqliteTraversableResources:
return SqliteTraversableResources(fullname, self.accessor)


def load(database: pathlib.Path | str | sqlite3.Connection) -> None:
if isinstance(database, (pathlib.Path, str)):
Expand All @@ -88,3 +95,102 @@ def locate_file(self, path: typing.Any) -> pathlib.Path:

def read_text(self, filename: str) -> str:
return self.__accessor.get_file(f"{self.__name}-%/{filename}")


class SqliteTraversableResources(TraversableResources):
def __init__(self, fullname: str, accessor: Accessor) -> None:
self.fullname = fullname
self.accessor = accessor

def files(self) -> SqliteTraversable:
return SqliteTraversable(self.fullname, self.accessor)


class SqliteTraversable(Traversable):
def __init__(self, path: str, accessor: Accessor) -> None:
self._path = path
self._accessor = accessor

def iterdir(self) -> typing.Iterator[SqliteTraversable]:
for path in self._accessor.list_directory(self._path):
yield SqliteTraversable(path, self._accessor)

def joinpath(self, *descendants: str) -> SqliteTraversable:
return SqliteTraversable(
f"{self._path}/{'/'.join(descendants)}", self._accessor
)

def __truediv__(self, other: str) -> SqliteTraversable:
return self.joinpath(other)

def is_dir(self) -> bool:
return False

def is_file(self) -> bool:
return True

@typing.overload
def open(
self,
mode: typing.Literal["r"] = ...,
*,
encoding: str | None = ...,
errors: str | None = ...,
) -> io.StringIO: ...

@typing.overload
def open(
self,
mode: typing.Literal["rb"] = ...,
*,
encoding: str | None = ...,
errors: str | None = ...,
) -> io.BytesIO: ...

def open(
self, mode: str = "r", *args: typing.Any, **kwargs: typing.Any
) -> io.StringIO | io.BytesIO:
content = self._accessor.get_file(self._path)
if "b" in mode:
return io.BytesIO(content.encode("utf-8"))

return io.StringIO(content)

def read_text(self, encoding: str | None = None, errors: str | None = None) -> str:
return self._accessor.get_file(self._path)

def read_bytes(self) -> bytes:
return self._accessor.get_file(self._path).encode("utf-8")

@property
def name(self) -> str:
return pathlib.PurePosixPath(self._path).name


# noinspection PyUnresolvedReferences,PyProtectedMember
def _patch_python_39_from_package() -> None:
# Python 3.9's `from_package()` implementation simply returns a `pathlib.Path`,
# so the function must be patched to support sqlite-backed resource access.
import functools
import importlib._common # type: ignore[import-not-found]

original_from_package: typing.Callable[[types.ModuleType], Traversable] = (
importlib._common.from_package
)

@functools.wraps(original_from_package)
def _from_package(package: types.ModuleType) -> Traversable:
spec = package.__spec__
if spec is None:
return original_from_package(package)

loader = spec.loader
if not isinstance(loader, SqliteLoader):
return original_from_package(package)
return loader.get_resource_reader(spec.name).files()

importlib._common.from_package = _from_package


if sys.version_info < (3, 10):
_patch_python_39_from_package()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pip
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Metadata-Version: 2.1
Name: package-resources-filesystem
Version: 1.1.1
Summary:
License: MIT
Author: Kurt McKee
Author-email: [email protected]
Requires-Python: >=3.7
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Description-Content-Type: text/x-rst

A package that contains resources.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package_resources_filesystem-1.1.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
package_resources_filesystem-1.1.1.dist-info/METADATA,sha256=bRIR9byrlEeRogVJ__XF1rj8YwIg5ocgRLv0rpyAY7k,702
package_resources_filesystem-1.1.1.dist-info/RECORD,,
package_resources_filesystem-1.1.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
package_resources_filesystem-1.1.1.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
package_resources_filesystem-1.1.1.dist-info/direct_url.json,,
package_resources_filesystem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
package_resources_filesystem/resource.txt,sha256=UzUTwTl8uMzsBYUrUlFL7NX9jJwhUJ97wvXUYMYUPdg,9
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry-core 1.9.1
Root-Is-Purelib: true
Tag: py3-none-any
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"archive_info": {"hash": "sha256=fe8e939ef9b5fe64eddd0c3ddb0d0f6e3f4e0d61b76323346068549631b62354", "hashes": {"sha256": "fe8e939ef9b5fe64eddd0c3ddb0d0f6e3f4e0d61b76323346068549631b62354"}}, "url": "file:///package_resources_filesystem-1.1.1-py3-none-any.whl"}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
resource
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pip
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Metadata-Version: 2.1
Name: package-resources-sqlite
Version: 2.2.2
Summary:
License: MIT
Author: Kurt McKee
Author-email: [email protected]
Requires-Python: >=3.7
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Description-Content-Type: text/x-rst

A package that contains resources.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package_resources_sqlite-2.2.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
package_resources_sqlite-2.2.2.dist-info/METADATA,sha256=sJmD_MkvHCoc0BAKdIAQoMgRVzumA4l0xOC7kqEO5RE,698
package_resources_sqlite-2.2.2.dist-info/RECORD,,
package_resources_sqlite-2.2.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
package_resources_sqlite-2.2.2.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
package_resources_sqlite-2.2.2.dist-info/direct_url.json,,
package_resources_sqlite/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
package_resources_sqlite/resource.txt,sha256=UzUTwTl8uMzsBYUrUlFL7NX9jJwhUJ97wvXUYMYUPdg,9
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry-core 1.9.1
Root-Is-Purelib: true
Tag: py3-none-any
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"archive_info": {"hash": "sha256=a7b9f6e2cdb94e02c3a59ec026c473efc6b6e27385438574733e97b6d95385e9", "hashes": {"sha256": "a7b9f6e2cdb94e02c3a59ec026c473efc6b6e27385438574733e97b6d95385e9"}}, "url": "file:///package_resources_sqlite-2.2.2-py3-none-any.whl"}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
resource
39 changes: 37 additions & 2 deletions tests/regenerate-test-projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def build_wheel(directory: pathlib.Path, output: pathlib.Path) -> None:
"""Build a wheel using Poetry."""

command = f"poetry build --format=wheel --directory={directory} --output={output}"
subprocess.check_call(command.split())
run_command(command.split())


def install_wheels(target: pathlib.Path, dist_directory: pathlib.Path) -> None:
Expand All @@ -58,7 +58,7 @@ def install_wheels(target: pathlib.Path, dist_directory: pathlib.Path) -> None:
*f"{sys.executable} -m pip install --target={target}".split(),
*[str(path) for path in dist_directory.glob("*.whl")],
]
subprocess.check_call(command)
run_command(command)


def sanitize_dist_info_records(directory: pathlib.Path) -> None:
Expand Down Expand Up @@ -99,5 +99,40 @@ def sanitize_dist_info_records(directory: pathlib.Path) -> None:
record_file.write_text("\n".join(output), newline=newline)


def run_command(command: list[str]) -> None:
try:
subprocess.run(
command,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as error:
stdout = (error.stdout or b"<NONE>").decode("utf-8", errors="replace")
stderr = (error.stderr or b"<NONE>").decode("utf-8", errors="replace")
print("COMMAND")
print("=======")
print()
print(" ".join(error.cmd))
print()
print()
print("RETURN CODE")
print("===========")
print()
print(error.returncode)
print()
print()
print("STDOUT")
print("======")
print()
print(stdout)
print()
print()
print("STDERR")
print("======")
print()
print(stderr)
raise


if __name__ == "__main__":
sys.exit(main())
1 change: 1 addition & 0 deletions tests/source-projects/package-resources/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A package that contains resources.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A package that contains resources.
18 changes: 18 additions & 0 deletions tests/source-projects/package-resources/filesystem/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"


# poetry
# ------

[tool.poetry]
name = "package-resources-filesystem"
version = "1.1.1"
description = ""
authors = ["Kurt McKee <[email protected]>"]
license = "MIT"
readme = "README.rst"

[tool.poetry.dependencies]
python = ">=3.7"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
resource
1 change: 1 addition & 0 deletions tests/source-projects/package-resources/sqlite/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A package that contains resources.
18 changes: 18 additions & 0 deletions tests/source-projects/package-resources/sqlite/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"


# poetry
# ------

[tool.poetry]
name = "package-resources-sqlite"
version = "2.2.2"
description = ""
authors = ["Kurt McKee <[email protected]>"]
license = "MIT"
readme = "README.rst"

[tool.poetry.dependencies]
python = ">=3.7"
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
resource
Loading

0 comments on commit 3f51490

Please sign in to comment.