Skip to content

Commit

Permalink
add initial package and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuadavidthomas committed May 11, 2024
1 parent 1cfbeba commit e34c497
Show file tree
Hide file tree
Showing 11 changed files with 478 additions and 41 deletions.
2 changes: 0 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ def should_skip(python: str, django: str) -> bool:
# Django main requires Python 3.10+
return True



if django == DJ50 and version(python) < version(PY310):
# Django 5.0 requires Python 3.10+
return True
Expand Down
57 changes: 18 additions & 39 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ build-backend = "hatchling.build"
requires = ["hatchling"]

[project]
authors = [
{name = "Josh Thomas", email = "[email protected]"}
]
authors = [{ name = "Josh Thomas", email = "[email protected]" }]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: Django",
Expand All @@ -21,15 +19,13 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython"
]
dependencies = [
"django>=4.2"
"Programming Language :: Python :: Implementation :: CPython",
]
dependencies = ["django>=4.2"]
description = "A custom Django field for storing and securely accessing a 1Password vault item."
dynamic = ["version"]
keywords = []
license = {file = "LICENSE"}
license = { file = "LICENSE" }
name = "django-opfield"
readme = "README.md"
requires-python = ">=3.8"
Expand All @@ -52,7 +48,7 @@ dev = [
"pytest-django",
"pytest-randomly",
"pytest-reverse",
"pytest-xdist"
"pytest-xdist",
]
docs = [
"cogapp",
Expand All @@ -61,7 +57,7 @@ docs = [
"sphinx",
"sphinx-autobuild",
"sphinx-copybutton",
"sphinx-inline-tabs"
"sphinx-inline-tabs",
]
lint = ["pre-commit"]

Expand All @@ -74,20 +70,14 @@ Source = "https://github.com/westerveltco/django-opfield"
commit = true
commit_message = ":bookmark: bump version {old_version} -> {new_version}"
current_version = "0.1.0"
push = false # set to false for CI
push = false # set to false for CI
tag = false
version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"

[tool.bumpver.file_patterns]
".copier/package.yml" = [
'current_version: {version}'
]
"src/django_opfield/__init__.py" = [
'__version__ = "{version}"'
]
"tests/test_version.py" = [
'assert __version__ == "{version}"'
]
".copier/package.yml" = ['current_version: {version}']
"src/django_opfield/__init__.py" = ['__version__ = "{version}"']
"tests/test_version.py" = ['assert __version__ == "{version}"']

[tool.coverage.paths]
source = ["src"]
Expand All @@ -99,15 +89,12 @@ exclude_lines = [
"if not DEBUG:",
"if settings.DEBUG:",
"if TYPE_CHECKING:",
'def __str__\(self\)\s?\-?\>?\s?\w*\:'
'def __str__\(self\)\s?\-?\>?\s?\w*\:',
]
fail_under = 75

[tool.coverage.run]
omit = [
"src/django_opfield/migrations/*",
"tests/*"
]
omit = ["src/django_opfield/migrations/*", "tests/*"]
source = ["django_opfield"]

[tool.django-stubs]
Expand All @@ -118,10 +105,7 @@ strict_settings = false
indent = 2

[tool.hatch.build]
exclude = [
".*",
"Justfile"
]
exclude = [".*", "Justfile"]

[tool.hatch.build.targets.wheel]
packages = ["src/django_opfield"]
Expand All @@ -134,20 +118,15 @@ check_untyped_defs = true
exclude = "docs/.*\\.py$"
mypy_path = "src/"
no_implicit_optional = true
plugins = [
"mypy_django_plugin.main"
]
plugins = ["mypy_django_plugin.main"]
warn_redundant_casts = true
warn_unused_configs = true
warn_unused_ignores = true

[[tool.mypy.overrides]]
ignore_errors = true
ignore_missing_imports = true
module = [
"django_opfield.*.migrations.*",
"tests.*"
]
module = ["django_opfield.*.migrations.*", "tests.*"]

[tool.mypy_django_plugin]
ignore_missing_model_attributes = true
Expand Down Expand Up @@ -180,7 +159,7 @@ exclude = [
"dist",
"migrations",
"node_modules",
"venv"
"venv",
]
extend-include = ["*.pyi?"]
indent-width = 4
Expand All @@ -202,13 +181,13 @@ quote-style = "double"
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["A", "B", "C", "D", "E", "F", "I"]
ignore = ["E501", "E741"] # temporary
ignore = ["E501", "E741"] # temporary
select = [
"B", # flake8-bugbear
"E", # Pycodestyle
"F", # Pyflakes
"I", # isort
"UP" # pyupgrade
"UP", # pyupgrade
]
unfixable = []

Expand Down
9 changes: 9 additions & 0 deletions src/django_opfield/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from __future__ import annotations

from django.apps import AppConfig


class DjangoOPFieldConfig(AppConfig):
name = "django_opfield"
label = "django_opfield"
verbose_name = "Django OPField"
32 changes: 32 additions & 0 deletions src/django_opfield/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from typing import Any

from django.conf import settings

if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import (
override, # pyright: ignore[reportUnreachable] # pragma: no cover
)

OPFIELD_SETTINGS_NAME = "DJANGO_OPFIELD"


@dataclass(frozen=True)
class AppSettings:
@override
def __getattribute__(self, __name: str) -> Any:
user_settings = getattr(settings, OPFIELD_SETTINGS_NAME, {})
return user_settings.get(__name, super().__getattribute__(__name))

@property
def OP_SERVICE_ACCOUNT_TOKEN(self) -> str:
return os.environ.get("OP_SERVICE_ACCOUNT_TOKEN", "")


app_settings = AppSettings()
64 changes: 64 additions & 0 deletions src/django_opfield/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

import shutil
import subprocess
import sys
from typing import Any

from django.db import models

from django_opfield.conf import app_settings
from django_opfield.validators import OPURIValidator

if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import (
override, # pyright: ignore[reportUnreachable] # pragma: no cover
)


class OPField(models.CharField):
description = "1Password secret"

def __init__(
self, vaults: list[str] | None = None, *args: Any, **kwargs: Any
) -> None:
self.vaults = vaults
kwargs.setdefault("max_length", 255)
super().__init__(*args, **kwargs)
self.validators.append(OPURIValidator(vaults=self.vaults))

@classmethod
def with_secret(cls, *args: Any, **kwargs: Any) -> tuple[OPField, property]:
op_uri = cls(*args, **kwargs)

@property
def secret(self: models.Model) -> str | None:
if not app_settings.OP_SERVICE_ACCOUNT_TOKEN:
msg = "OP_SERVICE_ACCOUNT_TOKEN is not set"
raise ValueError(msg)
if shutil.which("op") is None:
msg = "The 'op' CLI command is not available"
raise OSError(msg)
field_value = getattr(self, op_uri.name)
result = subprocess.run(["op", "read", field_value], capture_output=True)
if result.returncode != 0:
err = result.stderr.decode("utf-8")
msg = f"Could not read secret from 1Password: {err}"
raise ValueError(msg)
secret = result.stdout.decode("utf-8").strip()
return secret

@secret.setter
def secret(self: models.Model, value: str) -> None:
raise NotImplementedError("OPField does not support setting secret values")

return op_uri, secret

@override
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.vaults is not None:
kwargs["vaults"] = self.vaults
return name, path, args, kwargs
Empty file added src/django_opfield/py.typed
Empty file.
64 changes: 64 additions & 0 deletions src/django_opfield/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

import re
import sys
from typing import Any

from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.deconstruct import deconstructible
from django.utils.regex_helper import (
_lazy_re_compile, # pyright: ignore[reportPrivateUsage]
)

if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import (
override, # pyright: ignore[reportUnreachable] # pragma: no cover
)


@deconstructible
class OPURIValidator(RegexValidator):
ul = "\u00a1-\uffff" # Unicode letters range (must not be a raw string).

op_path_re = (
r"(?P<vault>[^/]+)" # Vault: must be a non-empty string without slashes
r"/(?P<item>[^/]+)" # Item: must be a non-empty string without slashes
r"(?:/(?P<section>[^/]+))?" # Section: optional, non-empty string without slashes
r"/(?P<field>[^/]+)" # Field: must be a non-empty string without slashes
)

@property
def op_regex(self) -> re.Pattern[str]:
return _lazy_re_compile(
r"^op://" + self.op_path_re + r"$",
re.IGNORECASE,
)

message = "Enter a valid 1Password URI."
schemes = ["op"]

def __init__(self, vaults: list[str] | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.vaults = vaults if vaults is not None else []

@override
def __call__(self, value: Any) -> None:
if not isinstance(value, str) or len(value) > 2048:
raise ValidationError(self.message, code="invalid", params={"value": value})

# Match the URL against the regex to validate structure
match = self.op_regex.match(value)
if not match:
raise ValidationError(
self.message, code="invalid_format", params={"value": value}
)

# Check if the vault is in the list of valid vaults
vault = match.group("vault")
if self.vaults and vault not in self.vaults:
raise ValidationError(
f"The vault '{vault}' is not a valid vault.", code="invalid_vault"
)
22 changes: 22 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

import logging

from django.conf import settings

from .settings import DEFAULT_SETTINGS

pytest_plugins = []


def pytest_configure(config):
logging.disable(logging.CRITICAL)

settings.configure(**DEFAULT_SETTINGS, **TEST_SETTINGS)


TEST_SETTINGS = {
"INSTALLED_APPS": [
"tests",
]
}
9 changes: 9 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from __future__ import annotations

from django.db import models

from django_opfield.fields import OPField


class TestModel(models.Model):
op_uri, op_secret = OPField.with_secret()
Loading

0 comments on commit e34c497

Please sign in to comment.