poetry2nix/vendor/pyproject.nix/lib/pep508.nix

542 lines
16 KiB
Nix
Raw Normal View History

{ lib, pep440, pep599, pypa, ... }:
let
inherit (builtins) match elemAt split foldl' substring stringLength typeOf fromJSON isString head mapAttrs elem length;
inherit (lib) stringToCharacters fix;
2023-10-26 16:39:13 +13:00
inherit (import ./util.nix { inherit lib; }) splitComma stripStr;
re = {
operators = "([=><!~^]+)";
version = "([0-9.*x]+)";
};
# Assign numerical priority values to logical conditions so we can do proper precedence ordering
condPrio = {
and = 5;
or = 10;
not = 1;
"in" = -1;
"not in" = -1;
"" = -1;
};
condGt = l: r: if l == "" then false else condPrio.${l} >= condPrio.${r};
# Parse a value into an attrset of { type = "valueType"; value = ...; }
# Will parse any field name suffixed with "version" as a PEP-440 version, otherwise
# the value is passed through and the type is inferred with builtins.typeOf
parseValueVersionDynamic = name: value: (
if match "^.+version" name != null && isString value then {
type = "version";
value = pep440.parseVersion value;
} else {
type = typeOf value;
inherit value;
}
);
# Remove groupings ( ) from expression
unparen = expr':
let
expr = stripStr expr';
m = match "\\((.+)\\)" expr;
in
if m != null then elemAt m 0 else expr;
isMarkerVariable =
let
markerFields = [
"implementation_name"
"implementation_version"
"os_name"
"platform_machine"
"platform_python_implementation"
"platform_release"
"platform_system"
"platform_version"
"python_full_version"
"python_version"
"sys_platform"
"extra"
];
in
s: elem s markerFields;
unpackValue = value:
let
# If the value is a single ticked string we can't pass it plainly to toJSON.
# Normalise to a double quoted.
singleTicked = match "^'(.+)'$" value; # TODO: Account for escaped ' in input (unescape)
in
if isMarkerVariable value then value
else fromJSON (if singleTicked != null then "\"" + head singleTicked + "\"" else value);
# Comparators for simple equality
# For versions see pep440.comparators
comparators = {
"==" = a: b: a == b;
"!=" = a: b: a != b;
"<=" = a: b: a <= b;
">=" = a: b: a >= b;
"<" = a: b: a < b;
">" = a: b: a > b;
"===" = a: b: a == b;
};
# Special case comparators for the `extra` environment field
extraComparators = {
# Check for member in list if list, otherwise simply compare.
"==" = extras: extra: if typeOf extras == "list" then elem extra extras else extras == extra;
"!=" = extras: extra: if typeOf extras == "list" then !(elem extra extras) else extras != extra;
};
boolOps = {
"and" = x: y: x && y;
"or" = x: y: x || y;
"in" = x: y: lib.strings.hasInfix x y;
};
isPrimitiveType =
let
primitives = [
"int"
"float"
"string"
"bool"
];
in
type: elem type primitives;
in
fix (self:
{
/* Parse PEP 508 markers into an AST.
Type: parseMarkers :: string -> AttrSet
Example:
# parseMarkers "(os_name=='a' or os_name=='b') and os_name=='c'"
{
lhs = {
lhs = {
lhs = {
type = "variable";
value = "os_name";
};
op = "==";
rhs = {
type = "string";
value = "a";
};
type = "compare";
};
op = "or";
rhs = {
lhs = {
type = "variable";
value = "os_name";
};
op = "==";
rhs = {
type = "string";
value = "b";
};
type = "compare";
};
type = "boolOp";
};
op = "and";
rhs = {
lhs = {
type = "variable";
value = "os_name";
};
op = "==";
rhs = {
type = "string";
value = "c";
};
type = "compare";
};
type = "boolOp";
}
*/
parseMarkers = input:
let
# Find the positions of lhs/op/rhs in the input string
pos = foldl'
(acc: c:
let
# # Look ahead to find the operator (either "and", "not" or "or").
cond =
if self.openP > 0 || acc.inString then ""
else if substring acc.pos 5 input == " and " then "and"
else if substring acc.pos 4 input == " or " then "or"
else if substring acc.pos 4 input == " in " then "in"
else if substring acc.pos 8 input == " not in " then "not in"
else if substring acc.pos 5 input == " not " then "not"
else "";
# When we've reached the operator we know the start/end positions of lhs/op/rhs
rhsOffset =
if cond != "" && condGt cond acc.cond then
(
if (cond == "and" || cond == "not") then 5
else if (cond == "or" || cond == "in") then 4
else if cond == "not in" then 8
else throw "Unknown cond: ${cond}"
) else -1;
self = {
# If we are inside a string don't track the opening and closing of parens
openP = if acc.inString then acc.openP else
(
if c == "(" then acc.openP + 1
else if c == ")" then acc.openP - 1
else acc.openP
);
# Check opening and closing of strings
inString =
if acc.inString && c == "'" then true
else if !acc.inString && c == "'" then false
else acc.inString;
pos = acc.pos + 1;
cond = if cond != "" then cond else acc.cond;
lhs = if (rhsOffset != -1) then acc.pos else acc.lhs;
rhs = if (rhsOffset != -1) then (acc.pos + rhsOffset) else acc.rhs;
};
in
self)
{
openP = 0; # Number of open parens
inString = false; # If the parser is inside a string
pos = 0; # Parser position
done = false;
# Keep track of last logical condition to do precedence ordering
cond = "";
# Stop positions for each value
lhs = -1;
rhs = -1;
}
(stringToCharacters input);
in
if pos.lhs == -1 then # No right hand value to extract
(
let
m = split re.operators (unparen input);
mLength = length m;
mAt = elemAt m;
lhs = stripStr (mAt 0);
in
if (mLength > 1) then assert mLength == 3; {
type = "compare";
lhs =
if isMarkerVariable lhs then {
type = "variable";
value = lhs;
} else unpackValue lhs;
op = elemAt (mAt 1) 0;
rhs = parseValueVersionDynamic lhs (unpackValue (stripStr (mAt 2)));
} else if isMarkerVariable input then {
type = "variable";
value = input;
} else rec {
value = unpackValue input;
type = typeOf value;
}
) else {
type = "boolOp";
lhs = self.parseMarkers (unparen (substring 0 pos.lhs input));
op = substring (pos.lhs + 1) (pos.rhs - pos.lhs - 2) input;
rhs = self.parseMarkers (unparen (substring pos.rhs (stringLength input) input));
};
/* Parse a PEP-508 dependency string.
Type: parseString :: string -> AttrSet
Example:
# parseString "cachecontrol[filecache]>=0.13.0"
{
conditions = [
{
op = ">=";
version = {
dev = null;
epoch = 0;
local = null;
post = null;
pre = null;
release = [ 0 13 0 ];
};
}
];
markers = null;
name = "cachecontrol";
extras = [ "filecache" ];
url = null;
}
*/
parseString = input:
let
# Split the input into it's distinct parts: The package segment, URL and environment markers
tokens =
let
# Input has both @ and ; separators (both URL and markers)
# "name [fred,bar] @ http://foo.com ; python_version=='2.7'"
m1 = match "^(.+)@(.+);(.+)$" input;
# Input has ; separator (markers)
# "name [fred,bar] ; python_version=='2.7'"
m2 = match "^(.+);(.+)$" input;
# Input has @ separator (URL)
# "name [fred,bar] @ http://foo.com"
m3 = match "^(.+)@(.+)$" input;
in
if m1 != null then {
packageSegment = elemAt m1 0;
url = stripStr (elemAt m1 1);
markerSegment = elemAt m1 2;
}
else if m2 != null then {
packageSegment = elemAt m2 0;
url = null;
markerSegment = elemAt m2 1;
}
else if m3 != null then {
packageSegment = elemAt m3 0;
url = stripStr (elemAt m3 1);
markerSegment = null;
}
else
(
if match ".+\/.+" input != null then
# Input is a bare URL
{
packageSegment = null;
url = input;
markerSegment = null;
} else
# Input is a package name
{
packageSegment = input;
url = null;
markerSegment = null;
}
);
# Extract metadata from the package segment
package =
let
# Package has either both extras and version constraints or just extras
# "name [fred,bar]>=3.10"
# "name [fred,bar]"
m1 = match "(.+)\\[(.*)](.*)" tokens.packageSegment;
# Package has either version constraints or is bare
# "name>=3.2"
# "name"
m2 = match "([a-zA-Z0-9_\\.-]+)(.*)" tokens.packageSegment;
# The version conditions as a list of strings
conditions = map pep440.parseVersionCond (splitComma (if m1 != null then elemAt m1 2 else elemAt m2 1));
# Extras as a list of strings
#
# Based on PEP-508 alone it's not clear whether extras should be normalized or not.
# From discussion in https://github.com/pypa/packaging-problems/issues/230
# missing normalization seems like an oversight.
extras = if m1 != null then map pypa.normalizePackageName (splitComma (elemAt m1 1)) else [ ];
in
if tokens.packageSegment == null then {
name = null;
conditions = [ ];
extras = [ ];
} else
# Assert that either regex matched
assert m1 != null || m2 != null; {
# Based on PEP-508 alone it's not clear whether names should be normalized or not.
# From discussion in https://github.com/pypa/packaging-problems/issues/230
# this seems like an oversight and we _should_ actually canonicalize names at parse time.
name = pypa.normalizePackageName (stripStr (if m1 != null then elemAt m1 0 else elemAt m2 0));
inherit extras conditions;
};
in
{
inherit (package) name conditions extras;
inherit (tokens) url;
markers = if tokens.markerSegment == null then null else self.parseMarkers tokens.markerSegment;
};
/* Create an attrset of platform variables.
As described in https://peps.python.org/pep-0508/#environment-markers.
Type: mkEnviron :: derivation -> AttrSet
Example:
# mkEnviron pkgs.python3
{
implementation_name = {
type = "string";
value = "cpython";
};
implementation_version = {
type = "version";
value = {
dev = null;
epoch = 0;
local = null;
post = null;
pre = null;
release = [ 3 10 12 ];
};
};
os_name = {
type = "string";
value = "posix";
};
platform_machine = {
type = "string";
value = "x86_64";
};
platform_python_implementation = {
type = "string";
value = "CPython";
};
# platform_release maps to platform.release() which returns
# the running kernel version on Linux.
# Because this field is not reproducible it's left empty.
platform_release = {
type = "string";
value = "";
};
platform_system = {
type = "string";
value = "Linux";
};
# platform_version maps to platform.version() which also returns
# the running kernel version on Linux.
# Because this field is not reproducible it's left empty.
platform_version = {
type = "version";
value = {
dev = null;
epoch = 0;
local = null;
post = null;
pre = null;
release = [ ];
};
};
python_full_version = {
type = "version";
value = {
dev = null;
epoch = 0;
local = null;
post = null;
pre = null;
release = [ 3 10 12 ];
};
};
python_version = {
type = "version";
value = {
dev = null;
epoch = 0;
local = null;
post = null;
pre = null;
release = [ 3 10 ];
};
};
sys_platform = {
type = "string";
value = "linux";
};
}
*/
mkEnviron = python:
let
inherit (python) stdenv;
targetMachine = pep599.manyLinuxTargetMachines.${stdenv.targetPlatform.parsed.cpu.name} or null;
in
mapAttrs
parseValueVersionDynamic
{
os_name =
if python.pname == "jython" then "java"
else "posix";
sys_platform =
if stdenv.isLinux then "linux"
else if stdenv.isDarwin then "darwin"
else throw "Unsupported platform";
platform_machine = targetMachine;
platform_python_implementation =
let
impl = python.passthru.implementation;
in
if impl == "cpython" then "CPython"
else if impl == "pypy" then "PyPy"
else throw "Unsupported implementation ${impl}";
platform_release = ""; # Field not reproducible
platform_system =
if stdenv.isLinux then "Linux"
else if stdenv.isDarwin then "Darwin"
else throw "Unsupported platform";
platform_version = ""; # Field not reproducible
python_version = python.passthru.pythonVersion;
python_full_version = python.version;
implementation_name = python.passthru.implementation;
implementation_version = python.version;
};
/* Evaluate an environment as returned by `mkEnviron` against markers as returend by `parseMarkers`.
Type: evalMarkers :: AttrSet -> AttrSet -> bool
Example:
# evalMarkers (mkEnviron pkgs.python3) (parseMarkers "python_version < \"3.11\"")
true
*/
evalMarkers = environ: value: (
let
x = self.evalMarkers environ value.lhs;
y = self.evalMarkers environ value.rhs;
in
if value.type == "compare" then
(
(
# Version comparison
if value.lhs.type == "version" || value.rhs.type == "version" then pep440.comparators.${value.op}
# `Extra` environment marker comparison requires special casing because it's equality checks can
# == can be considered a `"key" in set` comparison when multiple extras are activated for a dependency.
# If we didn't treat it this way the check would become quadratic as `evalMarkers` only could check one extra at a time.
else if value.lhs.type == "variable" || value.lhs.value == "extra" then extraComparators.${value.op}
# Simple equality
else comparators.${value.op}
) x
y
)
else if value.type == "boolOp" then boolOps.${value.op} x y
else if value.type == "variable" then (self.evalMarkers environ environ.${value.value})
else if value.type == "version" || value.type == "extra" then value.value
else if isPrimitiveType value.type then value.value
else throw "Unknown type '${value.type}'"
);
})