From e4b3b1202f2939cf5879c95a53db2cf307db8f13 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Feb 2026 21:56:23 -0800 Subject: [PATCH] C2(authentik-source-build): impl Python backend derivation and python314 compat overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../authentik/astor-python314-compat.patch | 99 +++++++ containers/authentik/authentik-django.nix | 279 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 containers/authentik/astor-python314-compat.patch create mode 100644 containers/authentik/authentik-django.nix diff --git a/containers/authentik/astor-python314-compat.patch b/containers/authentik/astor-python314-compat.patch new file mode 100644 index 0000000..1a82ef6 --- /dev/null +++ b/containers/authentik/astor-python314-compat.patch @@ -0,0 +1,99 @@ +From d0b5563cc1e263f08df9312d89a7691167448f4d Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= +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)), diff --git a/containers/authentik/authentik-django.nix b/containers/authentik/authentik-django.nix new file mode 100644 index 0000000..6531007 --- /dev/null +++ b/containers/authentik/authentik-django.nix @@ -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