Replace PyO3 with IPC approach for Python/project information (#214)
Some checks are pending
lint / pre-commit (push) Waiting to run
lint / rustfmt (push) Waiting to run
lint / clippy (push) Waiting to run
lint / cargo-check (push) Waiting to run
release / release (push) Blocked by required conditions
test / generate-matrix (push) Waiting to run
release / build (push) Waiting to run
release / test (push) Waiting to run
test / Python , Django () (push) Blocked by required conditions
test / tests (push) Blocked by required conditions
zizmor 🌈 / zizmor latest via PyPI (push) Waiting to run

This commit is contained in:
Josh Thomas 2025-09-09 19:08:42 -05:00 committed by GitHub
parent 31b0308a40
commit d99c96d6b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 903 additions and 696 deletions

12
python/Justfile Normal file
View file

@ -0,0 +1,12 @@
set unstable := true
[private]
default:
@just --list
[private]
fmt:
@just --fmt
build:
uv run build.py

24
python/build.py Normal file
View file

@ -0,0 +1,24 @@
from __future__ import annotations
import zipapp
from pathlib import Path
def main():
source_dir = Path(__file__).parent / "src" / "djls_inspector"
output_file = Path(__file__).parent / "dist" / "djls_inspector.pyz"
output_file.parent.mkdir(exist_ok=True)
zipapp.create_archive(
source_dir,
target=output_file,
interpreter=None, # No shebang - will be invoked explicitly
compressed=True,
)
print(f"Successfully created {output_file}")
print(f"Size: {output_file.stat().st_size} bytes")
if __name__ == "__main__":
main()

12
python/pyproject.toml Normal file
View file

@ -0,0 +1,12 @@
[build-system]
requires = ["uv_build>=0.7.21,<0.8"]
build-backend = "uv_build"
[project]
authors = [
{ name = "Josh Thomas", email = "josh@joshthomas.dev" }
]
dependencies = []
name = "djls-inspector"
requires-python = ">=3.9"
version = "0.1.0"

View file

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import json
import sys
# When running from zipapp, we need to import without the package prefix
# since the zipapp root is already the package
try:
# Try direct import (when running as zipapp)
from inspector import DjlsResponse
from inspector import handle_request
except ImportError:
# Fall back to package import (when running with python -m)
from djls_inspector.inspector import DjlsResponse
from djls_inspector.inspector import handle_request
def main() -> None:
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
response = handle_request(request)
except json.JSONDecodeError as e:
response = DjlsResponse(ok=False, error=f"Invalid JSON: {e}")
except Exception as e:
response = DjlsResponse(ok=False, error=f"Unexpected error: {e}")
response_json = json.dumps(response.to_dict())
print(response_json, flush=True)
except KeyboardInterrupt:
sys.exit(0)
except Exception as e:
print(f"Fatal error in inspector: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import asdict
from dataclasses import dataclass
from typing import Any
try:
# Try direct import (when running as zipapp)
from queries import Query
from queries import QueryData
from queries import get_installed_templatetags
from queries import get_python_environment_info
from queries import initialize_django
except ImportError:
# Fall back to relative import (when running with python -m)
from .queries import Query
from .queries import QueryData
from .queries import get_installed_templatetags
from .queries import get_python_environment_info
from .queries import initialize_django
@dataclass
class DjlsRequest:
query: Query
args: list[str] | None = None
@dataclass
class DjlsResponse:
ok: bool
data: QueryData | None = None
error: str | None = None
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
# Convert Path objects to strings for JSON serialization
if self.data:
if hasattr(self.data, "__dataclass_fields__"):
data_dict = asdict(self.data)
# Convert Path objects to strings
for key, value in data_dict.items():
if key in ["sys_base_prefix", "sys_executable", "sys_prefix"]:
if value:
data_dict[key] = str(value)
elif key == "sys_path":
data_dict[key] = [str(p) for p in value]
d["data"] = data_dict
return d
def handle_request(request: dict[str, Any]) -> DjlsResponse:
try:
query_str = request.get("query")
if not query_str:
return DjlsResponse(ok=False, error="Missing 'query' field in request")
try:
query = Query(query_str)
except ValueError:
return DjlsResponse(ok=False, error=f"Unknown query type: {query_str}")
args = request.get("args")
if query == Query.PYTHON_ENV:
return DjlsResponse(ok=True, data=get_python_environment_info())
elif query == Query.TEMPLATETAGS:
return DjlsResponse(ok=True, data=get_installed_templatetags())
elif query == Query.DJANGO_INIT:
return DjlsResponse(ok=True, data=initialize_django())
return DjlsResponse(ok=False, error=f"Unhandled query type: {query}")
except Exception as e:
return DjlsResponse(ok=False, error=str(e))

View file

@ -0,0 +1,174 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal
class Query(str, Enum):
PYTHON_ENV = "python_env"
TEMPLATETAGS = "templatetags"
DJANGO_INIT = "django_init"
@dataclass
class PythonEnvironmentQueryData:
sys_base_prefix: Path
sys_executable: Path
sys_path: list[Path]
sys_platform: str
sys_prefix: Path
sys_version_info: tuple[
int, int, int, Literal["alpha", "beta", "candidate", "final"], int
]
def get_python_environment_info():
return PythonEnvironmentQueryData(
sys_base_prefix=Path(sys.base_prefix),
sys_executable=Path(sys.executable),
sys_path=[Path(p) for p in sys.path],
sys_platform=sys.platform,
sys_prefix=Path(sys.prefix),
sys_version_info=(
sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro,
sys.version_info.releaselevel,
sys.version_info.serial,
),
)
@dataclass
class TemplateTagQueryData:
templatetags: list[TemplateTag]
@dataclass
class TemplateTag:
name: str
module: str
doc: str | None
def get_installed_templatetags() -> TemplateTagQueryData:
import django
from django.apps import apps
from django.template.engine import Engine
from django.template.library import import_library
# Ensure Django is set up
if not apps.ready:
django.setup()
templatetags: list[TemplateTag] = []
engine = Engine.get_default()
for library in engine.template_builtins:
if library.tags:
for tag_name, tag_func in library.tags.items():
templatetags.append(
TemplateTag(
name=tag_name, module=tag_func.__module__, doc=tag_func.__doc__
)
)
for lib_module in engine.libraries.values():
library = import_library(lib_module)
if library and library.tags:
for tag_name, tag_func in library.tags.items():
templatetags.append(
TemplateTag(
name=tag_name, module=tag_func.__module__, doc=tag_func.__doc__
)
)
return TemplateTagQueryData(templatetags=templatetags)
@dataclass
class DjangoInitQueryData:
success: bool
message: str | None = None
def initialize_django() -> DjangoInitQueryData:
import os
import django
from django.apps import apps
try:
# Check if Django settings are configured
if not os.environ.get("DJANGO_SETTINGS_MODULE"):
# Try to find and set settings module
import sys
from pathlib import Path
# Look for manage.py to determine project structure
current_path = Path.cwd()
manage_py = None
# Search up to 3 levels for manage.py
for _ in range(3):
if (current_path / "manage.py").exists():
manage_py = current_path / "manage.py"
break
if current_path.parent == current_path:
break
current_path = current_path.parent
if not manage_py:
return DjangoInitQueryData(
success=False,
message="Could not find manage.py or DJANGO_SETTINGS_MODULE not set",
)
# Add project directory to sys.path
project_dir = manage_py.parent
if str(project_dir) not in sys.path:
sys.path.insert(0, str(project_dir))
# Try to find settings module - look for common patterns
# First check if there's a directory with the same name as the parent
project_name = project_dir.name
settings_candidates = [
f"{project_name}.settings", # e.g., myproject.settings
"settings", # Just settings.py in root
"config.settings", # Common pattern
"project.settings", # Another common pattern
]
# Also check for any directory containing settings.py
for item in project_dir.iterdir():
if item.is_dir() and (item / "settings.py").exists():
candidate = f"{item.name}.settings"
if candidate not in settings_candidates:
settings_candidates.insert(
0, candidate
) # Prioritize found settings
for settings_candidate in settings_candidates:
try:
__import__(settings_candidate)
os.environ["DJANGO_SETTINGS_MODULE"] = settings_candidate
break
except ImportError:
continue
# Set up Django
if not apps.ready:
django.setup()
return DjangoInitQueryData(
success=True, message="Django initialized successfully"
)
except Exception as e:
return DjangoInitQueryData(success=False, message=str(e))
QueryData = PythonEnvironmentQueryData | TemplateTagQueryData | DjangoInitQueryData

8
python/uv.lock generated Normal file
View file

@ -0,0 +1,8 @@
version = 1
revision = 2
requires-python = ">=3.9"
[[package]]
name = "djls-inspector"
version = "0.1.0"
source = { editable = "." }