Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 19 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The library is async-only at the moment (following gidgethub), with sync support
> from pathlib import Path
>
> GITHUB_APP = {
> "PRIVATE_KEY": Path(env.path("GITHUB_PRIVATE_KEY_PATH")).read_text(),
> "PRIVATE_KEY": env.path("GITHUB_PRIVATE_KEY_PATH"),
> }
> ```

Expand Down Expand Up @@ -312,45 +312,44 @@ The GitHub App's name as registered on GitHub.

### `PRIVATE_KEY`

> ❗ **Required** | `str`
> 🔴 **Required** | `str`

The GitHub App's private key for authentication. Can be provided as either:

The contents of the GitHub App's private key for authentication. Can be provided as:
- Raw key contents (e.g., from environment variable)
- Path to key file (as string or Path object)

You can provide the key contents directly:
The library will automatically detect and read the key file if a path is provided.

```python
from pathlib import Path
from environs import Env

env = Env()

# Direct key contents from environment variable
# Key contents from environment
GITHUB_APP = {
"PRIVATE_KEY": env.str("GITHUB_PRIVATE_KEY"),
}
```

Or read it from a file:

```python
from pathlib import Path

from environs import Env

# Using pathlib.Path and a local path directly
# Path to local key file (as string)
GITHUB_APP = {
"PRIVATE_KEY": Path("path/to/private-key.pem").read_text(),
"PRIVATE_KEY": "/path/to/private-key.pem",
}

# Path to local key file (as Path object)
GITHUB_APP = {
"PRIVATE_KEY": Path("path/to/private-key.pem"),
}

env = Env()

# Or with environs for environment-based path
# Path from environment
GITHUB_APP = {
"PRIVATE_KEY": Path(env.path("GITHUB_PRIVATE_KEY_PATH")).read_text(),
"PRIVATE_KEY": env.path("GITHUB_PRIVATE_KEY_PATH"),
}
```

Note that the private key should be kept secure and never committed to version control. Using environment variables or secure file storage is recommended.
> [!NOTE]
> The private key should be kept secure and never committed to version control. Using environment variables or secure file storage is recommended.

### `WEBHOOK_SECRET`

Expand Down
25 changes: 24 additions & 1 deletion src/django_github_app/conf.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

from django.conf import settings
Expand All @@ -24,7 +25,29 @@ class AppSettings:
@override
def __getattribute__(self, __name: str) -> Any:
user_settings = getattr(settings, GITHUB_APP_SETTINGS_NAME, {})
return user_settings.get(__name, super().__getattribute__(__name))
value = user_settings.get(__name, super().__getattribute__(__name))

match __name:
case "PRIVATE_KEY":
return self._parse_private_key(value)
case _:
return value

def _parse_private_key(self, value: Any) -> str:
if not value:
return ""

if not isinstance(value, (str, Path)):
return str(value)

if isinstance(value, str) and value.startswith("-----BEGIN"):
return value

path = value if isinstance(value, Path) else Path(value)
if path.is_file():
return path.read_text()

return str(value)

@property
def SLUG(self):
Expand Down
37 changes: 35 additions & 2 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from pathlib import Path

import pytest
from django.conf import settings

Expand All @@ -26,6 +28,37 @@ def test_default_settings(setting, default_setting):
assert getattr(app_settings, setting) == default_setting


@pytest.mark.parametrize(
"private_key,expected",
[
(
"-----BEGIN RSA PRIVATE KEY-----\nkey content\n-----END RSA PRIVATE KEY-----",
"-----BEGIN RSA PRIVATE KEY-----\nkey content\n-----END RSA PRIVATE KEY-----",
),
("/path/that/does/not/exist.pem", "/path/that/does/not/exist.pem"),
(Path("/path/that/does/not/exist.pem"), "/path/that/does/not/exist.pem"),
("", ""),
("/path/with/BEGIN/in/it/key.pem", "/path/with/BEGIN/in/it/key.pem"),
("////", "////"),
(123, "123"),
(None, ""),
],
)
def test_private_key_handling(private_key, expected, override_app_settings):
with override_app_settings(PRIVATE_KEY=private_key):
assert app_settings.PRIVATE_KEY == expected


def test_private_key_from_file(tmp_path, override_app_settings):
key_content = "-----BEGIN RSA PRIVATE KEY-----\ntest key content\n-----END RSA PRIVATE KEY-----"
key_file = tmp_path / "test_key.pem"
key_file.write_text(key_content)

for key_path in (str(key_file), key_file):
with override_app_settings(PRIVATE_KEY=key_path):
assert app_settings.PRIVATE_KEY == key_content


@pytest.mark.parametrize(
"name,expected",
[
Expand All @@ -40,8 +73,8 @@ def test_default_settings(setting, default_setting):
("special-&*()-chars", "special-chars"),
("emoji🚀app", "emojiapp"),
("@user/multiple/slashes/app", "usermultipleslashesapp"),
("", ""), # Empty string case
(" ", ""), # Whitespace only case
("", ""),
(" ", ""),
("app-name_123", "app-name_123"),
("v1.0.0-beta", "v100-beta"),
],
Expand Down