C2(authentik-source-build): impl Python backend derivation and python314 compat overrides

WIP: authentik-django.nix with python314 overrides (django_5, astor
patch, dacite test skip, exceptiongroup test skip). Build not yet
passing — pydantic-core 2.33.2 fails because PyO3 0.24.1 caps at
Python 3.13. Needs either PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 env
var or a newer nixpkgs snapshot with PyO3 >= 0.25.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-02-28 21:56:23 -08:00
commit e4b3b1202f
2 changed files with 378 additions and 0 deletions

View file

@ -0,0 +1,99 @@
From d0b5563cc1e263f08df9312d89a7691167448f4d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= <mgorny@gentoo.org>
Date: Wed, 14 May 2025 19:52:30 +0200
Subject: [PATCH] Fix compatibility with Python 3.14 (mostly)
Fix the code and the test suite to work with Python 3.14, where
deprecated constant-like AST nodes were removed. Notably:
1. Skip tests for deprecated nodes in Python 3.14.
2. Use `ast.Constant` over `ast.Num` for non-deprecated code
in Python 3.6+.
3. Check for `ast.Str` only in Python < 3.14, and handle `ast.Constant`
being used to represent a string instead.
With these changes, all tests except for:
tests/test_rtrip.py::RtripTestCase::test_convert_stdlib
pass. However, this particular test also hanged for me with older Python
versions.
Related to #217
---
astor/code_gen.py | 9 +++++++--
tests/test_code_gen.py | 11 ++++++++---
2 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/astor/code_gen.py b/astor/code_gen.py
index b2bae12..4330f49 100644
--- a/astor/code_gen.py
+++ b/astor/code_gen.py
@@ -692,6 +692,7 @@ def _handle_string_constant(self, node, value, is_joined=False):
current_line = ''.join(current_line)
has_ast_constant = sys.version_info >= (3, 6)
+ has_ast_str = sys.version_info < (3, 14)
if is_joined:
# Handle new f-strings. This is a bit complicated, because
@@ -700,7 +701,7 @@ def _handle_string_constant(self, node, value, is_joined=False):
def recurse(node):
for value in node.values:
- if isinstance(value, ast.Str):
+ if has_ast_str and isinstance(value, ast.Str):
# Double up braces to escape them.
self.write(value.s.replace('{', '{{').replace('}', '}}'))
elif isinstance(value, ast.FormattedValue):
@@ -713,7 +714,11 @@ def recurse(node):
self.write(':')
recurse(value.format_spec)
elif has_ast_constant and isinstance(value, ast.Constant):
- self.write(value.value)
+ if isinstance(value.value, str):
+ # Double up braces to escape them.
+ self.write(value.value.replace('{', '{{').replace('}', '}}'))
+ else:
+ self.write(value.value)
else:
kind = type(value).__name__
assert False, 'Invalid node %s inside JoinedStr' % kind
diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py
index e828eb9..1825030 100644
--- a/tests/test_code_gen.py
+++ b/tests/test_code_gen.py
@@ -28,7 +28,10 @@ def astorexpr(x):
return eval(astor.to_source(ast.Expression(body=x)))
def astornum(x):
- return astorexpr(ast.Num(n=x))
+ if sys.version_info >= (3, 6):
+ return astorexpr(ast.Constant(x))
+ else:
+ return astorexpr(ast.Num(n=x))
class Comparisons(object):
@@ -515,8 +518,8 @@ def test_deprecated_constants_as_name(self):
ast.Assign(targets=[ast.Name(id='spam')], value=ast.Name(id='None')),
"spam = None")
- @unittest.skipUnless(sys.version_info >= (3, 4),
- "ast.NameConstant introduced in Python 3.4")
+ @unittest.skipUnless((3, 4) <= sys.version_info < (3, 14),
+ "ast.NameConstant introduced in Python 3.4, removed in 3.14")
def test_deprecated_name_constants(self):
self.assertAstEqualsSource(
ast.Assign(targets=[ast.Name(id='spam')], value=ast.NameConstant(value=True)),
@@ -530,6 +533,8 @@ def test_deprecated_name_constants(self):
ast.Assign(targets=[ast.Name(id='spam')], value=ast.NameConstant(value=None)),
"spam = None")
+ @unittest.skipIf(sys.version_info >= (3, 14),
+ "Deprecated Constant nodes removed in Python 3.14")
def test_deprecated_constant_nodes(self):
self.assertAstEqualsSource(
ast.Assign(targets=[ast.Name(id='spam')], value=ast.Num(3)),

View file

@ -0,0 +1,279 @@
# Authentik Python backend (authentik-django) — the Django/Python core
# Targets authentik 2026.2.0 with Python 3.14
#
# Usage:
# let
# sources = import ./sources.nix { inherit pkgs; };
# authentik-django = import ./authentik-django.nix { inherit pkgs; };
# in authentik-django
#
# The webui and website parameters are optional — when null (default),
# email icon paths are left unpatched and web/ is created empty.
# The final assembly card will wire these up once those derivations exist.
{ pkgs
, sources ? import ./sources.nix { inherit pkgs; }
, webui ? null
, website ? null
}:
let
inherit (sources) version src meta;
fetchFromGitHub = pkgs.fetchFromGitHub;
python = pkgs.python314.override {
self = python;
packageOverrides = final: prev: {
# --- Python 3.14 nixpkgs compatibility overrides ---
# See docs/how-to/authentik/python314-nixpkgs-compat.md
# python314 defaults to Django 4.2 which doesn't support Python 3.14
django = final.django_5;
# astor 0.8.1 uses ast.Num/ast.Str/ast.NameConstant removed in Python 3.14.
# Override with the same git snapshot + patch that current nixpkgs uses.
# https://github.com/berkerpeksag/astor/pull/233
astor = prev.astor.overrideAttrs (old: {
version = "0.8.1-unstable-2024-03-30";
src = fetchFromGitHub {
owner = "berkerpeksag";
repo = "astor";
rev = "df09001112f079db54e7c5358fa143e1e63e74c4";
hash = "sha256-VF+harl/q2yRU2yqN1Txud3YBNSeedQNw2SZNYQFsno=";
};
patches = (old.patches or []) ++ [
./astor-python314-compat.patch
];
});
# dacite test asserts on typing.Union[int, str] string repr, but Python 3.14
# renders it as "int | str". Cosmetic test failure — functionality is fine.
dacite = prev.dacite.overrideAttrs {
disabledTests = [ "test_from_dict_with_union_and_wrong_data" ];
};
# exceptiongroup tests expect RecursionError on deep nesting, but Python 3.14
# increased the recursion limit. Tests are for the backport; on 3.11+ the
# module is a no-op shim anyway.
exceptiongroup = prev.exceptiongroup.overrideAttrs {
disabledTests = [ "test_deep_split" "test_deep_subgroup" ];
};
# --- In-tree packages (built from monorepo source) ---
ak-guardian = final.buildPythonPackage {
pname = "ak-guardian";
inherit version src meta;
pyproject = true;
sourceRoot = "${src.name}/packages/ak-guardian";
build-system = [ final.hatchling ];
dependencies = [
final.django
final.typing-extensions
];
};
django-channels-postgres = final.buildPythonPackage {
pname = "django-channels-postgres";
inherit version src meta;
pyproject = true;
sourceRoot = "${src.name}/packages/django-channels-postgres";
build-system = [ final.hatchling ];
dependencies = [
final.channels
final.django
final.django-pgtrigger
final.msgpack
final.psycopg
final.structlog
] ++ final.psycopg.optional-dependencies.pool;
};
django-dramatiq-postgres = final.buildPythonPackage {
pname = "django-dramatiq-postgres";
inherit version src meta;
pyproject = true;
sourceRoot = "${src.name}/packages/django-dramatiq-postgres";
build-system = [ final.hatchling ];
dependencies = [
final.cron-converter
final.django
final.django-pglock
final.django-pgtrigger
final.dramatiq
final.structlog
final.tenacity
] ++ final.dramatiq.optional-dependencies.watch;
};
django-postgres-cache = final.buildPythonPackage {
pname = "django-postgres-cache";
inherit version src meta;
pyproject = true;
sourceRoot = "${src.name}/packages/django-postgres-cache";
build-system = [ final.hatchling ];
dependencies = [
final.django
final.django-postgres-extra
];
};
# --- Dependency overrides ---
# authentik is incompatible with dramatiq >=1.18:
# > AttributeError: 'Namespace' object has no attribute 'worker_fork_timeout'
dramatiq = prev.dramatiq.overrideAttrs (_: rec {
version = "1.17.1";
src = fetchFromGitHub {
owner = "Bogdanp";
repo = "dramatiq";
tag = "v${version}";
hash = "sha256-NeUGhG+H6r+JGd2qnJxRUbQ61G7n+3tsuDugTin3iJ4=";
};
});
# --- Main package ---
authentik-django = final.buildPythonPackage {
pname = "authentik-django";
inherit version meta;
src = sources.src;
pyproject = true;
postPatch =
''
substituteInPlace authentik/root/settings.py \
--replace-fail 'Path(__file__).absolute().parent.parent.parent' "Path(\"$out\")"
substituteInPlace authentik/lib/default.yml \
--replace-fail '/blueprints' "$out/blueprints"
# Always allow file upload if the data directory exists
substituteInPlace authentik/admin/files/backends/file.py \
--replace-fail "and (self._base_dir.is_mount() or (self._base_dir / self.usage.value).is_mount())" ""
''
+ pkgs.lib.optionalString (webui != null) ''
substituteInPlace authentik/stages/email/utils.py \
--replace-fail 'web/' '${webui}/'
'';
build-system = [
final.hatchling
];
pythonRemoveDeps = [ "dumb-init" ];
pythonRelaxDeps = true;
dependencies =
with final;
[
ak-guardian
argon2-cffi
cachetools
channels
cryptography
dacite
deepmerge
defusedxml
django
django-channels-postgres
django-countries
django-cte
django-dramatiq-postgres
django-filter
django-model-utils
django-pglock
django-pgtrigger
django-postgres-cache
django-postgres-extra
django-prometheus
django-storages
django-tenants
djangoql
djangorestframework
docker
drf-orjson-renderer
drf-spectacular
duo-client
fido2
geoip2
geopy
google-api-python-client
gunicorn
gssapi
jsonpatch
jwcrypto
kubernetes
ldap3
lxml
msgraph-sdk
opencontainers
packaging
paramiko
psycopg
pydantic
pydantic-scim
pyjwt
pyrad
python-kadmin-rs
pyyaml
requests-oauthlib
scim2-filter-parser
sentry-sdk
service-identity
setproctitle
structlog
swagger-spec-validator
twilio
ua-parser
unidecode
urllib3
uvicorn
watchdog
webauthn
wsproto
xmlsec
zxcvbn
]
++ django-storages.optional-dependencies.s3
++ psycopg.optional-dependencies.c
++ psycopg.optional-dependencies.pool
++ uvicorn.optional-dependencies.standard;
postInstall =
''
mkdir -p $out/web $out/website
cp -r lifecycle manage.py $out/${prev.python.sitePackages}/
cp -r blueprints $out/
''
+ pkgs.lib.optionalString (webui != null) ''
cp -r ${webui}/dist ${webui}/authentik $out/web/
''
+ pkgs.lib.optionalString (website != null) ''
cp -r ${website} $out/website/help
''
+ ''
ln -s $out/${prev.python.sitePackages}/authentik $out/authentik
ln -s $out/${prev.python.sitePackages}/lifecycle $out/lifecycle
'';
};
};
};
in
python.pkgs.authentik-django