Repository: railwayapp/nixpacks
Pull request #1258 (2025-01-10 23:27:34)

Select latest lts node version
When selecting a node version when we find a range in the `package.json` file, we should only select LTS versions (even version numbers).



Patch:
diff --git a/examples/python-psycopg2/database.py b/examples/python-psycopg2/database.py
new file mode 100644
index 0000000..682a926
--- /dev/null
+++ b/examples/python-psycopg2/database.py
@@ -0,0 +1,30 @@
+import os
+import psycopg2
+from psycopg2 import Error
+import time
+
+def get_db_connection():
+    max_retries = 3
+    retry_delay = 2  # seconds
+    
+    for attempt in range(max_retries):
+        try:
+            connection = psycopg2.connect(
+                host=os.getenv('PGHOST'),
+                database=os.getenv('PGDATABASE'),
+                user=os.getenv('PGUSER'),
+                password=os.getenv('PGPASSWORD'),
+                port=os.getenv('PGPORT')
+            )
+            return connection
+        except Error as e:
+            if "starting up" in str(e).lower():
+                if attempt < max_retries - 1:  # Don't sleep on last attempt
+                    time.sleep(retry_delay)
+                    continue
+            print(f"Error connecting to PostgreSQL: {e}")
+            return None
+
+def close_connection(connection):
+    if connection:
+        connection.close()
diff --git a/examples/python-psycopg2/main.py b/examples/python-psycopg2/main.py
new file mode 100644
index 0000000..0ba5707
--- /dev/null
+++ b/examples/python-psycopg2/main.py
@@ -0,0 +1,83 @@
+from database import get_db_connection, close_connection
+
+def create_table():
+    connection = get_db_connection()
+    if connection:
+        try:
+            cursor = connection.cursor()
+            
+            # Create a sample table
+            create_table_query = '''
+                CREATE TABLE IF NOT EXISTS users (
+                    id SERIAL PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    email VARCHAR(100) UNIQUE NOT NULL
+                )
+            '''
+            cursor.execute(create_table_query)
+            
+            # Commit the changes
+            connection.commit()
+            print("Table created successfully")
+            
+        except Exception as e:
+            print(f"Error: {e}")
+        finally:
+            cursor.close()
+            close_connection(connection)
+
+def insert_user(name, email):
+    connection = get_db_connection()
+    if connection:
+        try:
+            cursor = connection.cursor()
+            
+            # Insert a new user
+            insert_query = '''
+                INSERT INTO users (name, email)
+                VALUES (%s, %s)
+                RETURNING id
+            '''
+            cursor.execute(insert_query, (name, email))
+            user_id = cursor.fetchone()[0]
+            
+            # Commit the changes
+            connection.commit()
+            print(f"User inserted successfully with ID: {user_id}")
+            
+        except Exception as e:
+            print(f"Error: {e}")
+        finally:
+            cursor.close()
+            close_connection(connection)
+
+def get_all_users():
+    connection = get_db_connection()
+    if connection:
+        try:
+            cursor = connection.cursor()
+            
+            # Select all users
+            select_query = "SELECT * FROM users"
+            cursor.execute(select_query)
+            users = cursor.fetchall()
+            
+            for user in users:
+                print(f"ID: {user[0]}, Name: {user[1]}, Email: {user[2]}")
+            
+        except Exception as e:
+            print(f"Error: {e}")
+        finally:
+            cursor.close()
+            close_connection(connection)
+
+if __name__ == "__main__":
+    # Create the table
+    create_table()
+    
+    # Insert some sample users
+    insert_user("John Doe", "john@example.com")
+    insert_user("Jane Smith", "jane@example.com")
+    
+    # Retrieve and display all users
+    get_all_users()
diff --git a/examples/python-psycopg2/requirements.txt b/examples/python-psycopg2/requirements.txt
new file mode 100644
index 0000000..1b2d427
--- /dev/null
+++ b/examples/python-psycopg2/requirements.txt
@@ -0,0 +1,1 @@
+psycopg2-binary>=2.9.10
diff --git a/src/providers/node/mod.rs b/src/providers/node/mod.rs
index 902831c..157c738 100644
--- a/src/providers/node/mod.rs
+++ b/src/providers/node/mod.rs
@@ -634,14 +634,18 @@ fn parse_node_version_into_pkg(node_version: &str) -> String {
         eprintln!("Warning: node version {node_version} is not valid, using default node version {default_node_pkg_name}");
         Range::parse(DEFAULT_NODE_VERSION.to_string()).unwrap()
     });
-    let mut available_node_versions = AVAILABLE_NODE_VERSIONS.to_vec();
+    let mut available_lts_node_versions = AVAILABLE_NODE_VERSIONS
+        .iter()
+        .filter(|v| *v % 2 == 0)
+        .collect::<Vec<_>>();
+
     // use newest node version first
-    available_node_versions.sort_by(|a, b| b.cmp(a));
-    for version_number in available_node_versions {
+    available_lts_node_versions.sort_by(|a, b| b.cmp(a));
+    for version_number in available_lts_node_versions {
         let version_range_string = format!("{version_number}.x.x");
         let version_range: Range = version_range_string.parse().unwrap();
         if version_range.allows_any(&range) {
-            return version_number_to_pkg(version_number);
+            return version_number_to_pkg(*version_number);
         }
     }
     default_node_pkg_name
@@ -721,6 +725,24 @@ mod test {
         Ok(())
     }
 
+    #[test]
+    fn test_latest_lts_version() -> Result<()> {
+        assert_eq!(
+            NodeProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: Some(String::default()),
+                    engines: Some(engines_node(">=18")),
+                    ..Default::default()
+                },
+                &App::new("examples/node")?,
+                &Environment::default()
+            )?,
+            Pkg::new(version_number_to_pkg(22).as_str())
+        );
+
+        Ok(())
+    }
+
     #[test]
     fn test_simple_engine() -> Result<()> {
         assert_eq!(
diff --git a/src/providers/python.rs b/src/providers/python.rs
index db7e4bf..585e3f9 100644
--- a/src/providers/python.rs
+++ b/src/providers/python.rs
@@ -160,18 +160,15 @@ enum EntryPoint {
 
 impl PythonProvider {
     fn setup(&self, app: &App, env: &Environment) -> Result<Option<Phase>> {
+        let mut setup = Phase::setup(None);
+
         let mut pkgs: Vec<Pkg> = vec![];
         let (python_base_package, nix_archive) = PythonProvider::get_nix_python_package(app, env)?;
 
         pkgs.append(&mut vec![python_base_package]);
 
         if PythonProvider::is_using_postgres(app, env)? {
-            // Postgres requires postgresql and gcc on top of the original python packages
-
-            // .dev variant is required in order for pg_config to be available, which is needed by psycopg2
-            // the .dev variant requirement is caused by this change in nix pkgs:
-            // https://github.com/NixOS/nixpkgs/blob/43eac3c9e618c4114a3441b52949609ea2104670/pkgs/servers/sql/postgresql/pg_config.sh
-            pkgs.append(&mut vec![Pkg::new("postgresql.dev")]);
+            pkgs.append(&mut vec![Pkg::new("postgresql_16.dev")]);
         }
 
         if PythonProvider::is_django(app, env)? && PythonProvider::is_using_mysql(app, env)? {
@@ -183,7 +180,7 @@ impl PythonProvider {
             pkgs.append(&mut vec![Pkg::new("pipenv")]);
         }
 
-        let mut setup = Phase::setup(Some(pkgs));
+        setup.add_nix_pkgs(&pkgs);
         setup.set_nix_archive(nix_archive);
 
         if PythonProvider::uses_dep(app, "cairo")? {


Test patch:
diff --git a/tests/docker_run_tests.rs b/tests/docker_run_tests.rs
index 50ef814..352e7af 100644
--- a/tests/docker_run_tests.rs
+++ b/tests/docker_run_tests.rs
@@ -940,6 +940,48 @@ async fn test_python_asdf_poetry() {
     assert!(output.contains("Poetry (version 1.8.2)"), "{}", output);
 }
 
+#[tokio::test]
+async fn test_python_psycopg2() -> Result<()> {
+    // Create the network
+    let n = create_network();
+    let network_name = n.name.clone();
+
+    // Create the postgres instance
+    let c = run_postgres();
+    let container_name = c.name.clone();
+
+    // Attach the postgres instance to the network
+    attach_container_to_network(n.name, container_name.clone());
+
+    let name = match simple_build("./examples/python-psycopg2").await {
+        Ok(name) => name,
+        Err(err) => {
+            // Cleanup containers and networks, and then error
+            stop_and_remove_container(container_name);
+            remove_network(network_name);
+            return Err(err);
+        }
+    };
+
+    let output = run_image(
+        &name,
+        Some(Config {
+            environment_variables: c.config.unwrap().environment_variables,
+            network: Some(network_name.clone()),
+        }),
+    )
+    .await;
+
+    println!("OUTPUT = {}", output);
+
+    // Cleanup containers and networks
+    stop_and_remove_container(container_name);
+    remove_network(network_name);
+
+    assert!(output.contains("User inserted successfully with ID"));
+    Ok(())
+}
+
 #[tokio::test]
 async fn test_django() -> Result<()> {
     // Create the network
diff --git a/tests/snapshots/generate_plan_tests__node_pnpm_corepack.snap b/tests/snapshots/generate_plan_tests__node_pnpm_corepack.snap
index c2629e2..2e4c1a0 100644
--- a/tests/snapshots/generate_plan_tests__node_pnpm_corepack.snap
+++ b/tests/snapshots/generate_plan_tests__node_pnpm_corepack.snap
@@ -1,7 +1,6 @@
 ---
 source: tests/generate_plan_tests.rs
 expression: plan
-snapshot_kind: text
 ---
 {
   "providers": [],
@@ -44,7 +43,7 @@ snapshot_kind: text
     "setup": {
       "name": "setup",
       "nixPkgs": [
-        "nodejs_23",
+        "nodejs_22",
         "pnpm-9_x"
       ],
       "nixOverlays": [
diff --git a/tests/snapshots/generate_plan_tests__node_turborepo.snap b/tests/snapshots/generate_plan_tests__node_turborepo.snap
index 4550bd2..3c634e6 100644
--- a/tests/snapshots/generate_plan_tests__node_turborepo.snap
+++ b/tests/snapshots/generate_plan_tests__node_turborepo.snap
@@ -1,7 +1,6 @@
 ---
 source: tests/generate_plan_tests.rs
 expression: plan
-snapshot_kind: text
 ---
 {
   "providers": [],
@@ -45,7 +44,7 @@ snapshot_kind: text
     "setup": {
       "name": "setup",
       "nixPkgs": [
-        "nodejs_23",
+        "nodejs_22",
         "npm-8_x"
       ],
       "nixOverlays": [
diff --git a/tests/snapshots/generate_plan_tests__python_django.snap b/tests/snapshots/generate_plan_tests__python_django.snap
index 4821f83..8ab0711 100644
--- a/tests/snapshots/generate_plan_tests__python_django.snap
+++ b/tests/snapshots/generate_plan_tests__python_django.snap
@@ -1,7 +1,6 @@
 ---
 source: tests/generate_plan_tests.rs
 expression: plan
-snapshot_kind: text
 ---
 {
   "providers": [],
@@ -36,7 +35,7 @@ snapshot_kind: text
       "name": "setup",
       "nixPkgs": [
         "python3",
-        "postgresql.dev",
+        "postgresql_16.dev",
         "gcc"
       ],
       "nixLibs": [
diff --git a/tests/snapshots/generate_plan_tests__python_postgres.snap b/tests/snapshots/generate_plan_tests__python_postgres.snap
index 622bd3e..1b36516 100644
--- a/tests/snapshots/generate_plan_tests__python_postgres.snap
+++ b/tests/snapshots/generate_plan_tests__python_postgres.snap
@@ -1,7 +1,6 @@
 ---
 source: tests/generate_plan_tests.rs
 expression: plan
-snapshot_kind: text
 ---
 {
   "providers": [],
@@ -36,7 +35,7 @@ snapshot_kind: text
       "name": "setup",
       "nixPkgs": [
         "python3",
-        "postgresql.dev",
+        "postgresql_16.dev",
         "gcc"
       ],
       "nixLibs": [
diff --git a/tests/snapshots/generate_plan_tests__python_psycopg2.snap b/tests/snapshots/generate_plan_tests__python_psycopg2.snap
new file mode 100644
index 0000000..1b36516
--- /dev/null
+++ b/tests/snapshots/generate_plan_tests__python_psycopg2.snap
@@ -0,0 +1,52 @@
+---
+source: tests/generate_plan_tests.rs
+expression: plan
+---
+{
+  "providers": [],
+  "buildImage": "[build_image]",
+  "variables": {
+    "NIXPACKS_METADATA": "python,postgres",
+    "PIP_DEFAULT_TIMEOUT": "100",
+    "PIP_DISABLE_PIP_VERSION_CHECK": "1",
+    "PIP_NO_CACHE_DIR": "1",
+    "PYTHONDONTWRITEBYTECODE": "1",
+    "PYTHONFAULTHANDLER": "1",
+    "PYTHONHASHSEED": "random",
+    "PYTHONUNBUFFERED": "1"
+  },
+  "phases": {
+    "install": {
+      "name": "install",
+      "dependsOn": [
+        "setup"
+      ],
+      "cmds": [
+        "python -m venv --copies /opt/venv && . /opt/venv/bin/activate && pip install -r requirements.txt"
+      ],
+      "cacheDirectories": [
+        "/root/.cache/pip"
+      ],
+      "paths": [
+        "/opt/venv/bin"
+      ]
+    },
+    "setup": {
+      "name": "setup",
+      "nixPkgs": [
+        "python3",
+        "postgresql_16.dev",
+        "gcc"
+      ],
+      "nixLibs": [
+        "zlib",
+        "stdenv.cc.cc.lib"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    }
+  },
+  "start": {
+    "cmd": "python main.py"
+  }
+}