diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..68776ea5039cd86eee667636d3c90dde93abba61
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,45 @@
+{ pkgs ? import (fetchTarball {
+    url = "https://github.com/NixOS/nixpkgs/archive/22.11.tar.gz";
+    sha256 = "sha256:11w3wn2yjhaa5pv20gbfbirvjq6i3m7pqrq2msf0g7cv44vijwgw";
+  }) {}
+}:
+
+let
+  pyPkgs = pkgs.python3Packages;
+in rec {
+  ut3_survival = pyPkgs.buildPythonPackage {
+    pname = "ut3_survival";
+    version = "local";
+    format = "pyproject";
+
+    src = pkgs.lib.sourceByRegex ./. [
+      "pyproject\.toml"
+      "LICENSE"
+      "ut3_survival"
+      "ut3_survival/.*\.py"
+      "ut3_survival/cmd"
+      "ut3_survival/cmd/.*\.py"
+    ];
+    buildInputs = with pyPkgs; [
+      flit
+    ];
+    propagatedBuildInputs = with pyPkgs; [
+      xlrd
+      pandas
+      click
+    ];
+  };
+
+  user-shell = pkgs.mkShell {
+    buildInputs = with pyPkgs; [
+      ipython
+      ut3_survival
+    ];
+  };
+
+  dev-shell = pkgs.mkShell {
+    buildInputs = with pyPkgs; [
+      ipython
+    ] ++ ut3_survival.propagatedBuildInputs;
+  };
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000000000000000000000000000000000000..3476f0c6694484a4c86434a47a9135b3afdce056
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,42 @@
+{
+  "nodes": {
+    "flake-utils": {
+      "locked": {
+        "lastModified": 1667395993,
+        "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1670952872,
+        "narHash": "sha256-tmhCNibwoviM+BHXBWUxO+XtAwbO84b2BHC4PrD7FrQ=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "16875b3e7be8380c29af192cc8ff1debae6d311a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000000000000000000000000000000000000..241a8f8f9c2ce44c44b08481f797bc6f725705c9
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,16 @@
+{
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs?tag=22.11";
+    flake-utils.url = "github:numtide/flake-utils";
+  };
+
+  outputs = { self, nixpkgs, flake-utils }:
+    flake-utils.lib.eachDefaultSystem (system:
+      let pkgs = nixpkgs.legacyPackages.${system};
+      in rec {
+        packages = import ./default.nix { inherit pkgs; };
+        apps.xls-to-csv = flake-utils.lib.mkApp { drv = packages.ut3_survival; exePath = "/bin/xls-to-csv"; };
+        defaultPackage = packages.ut3_survival;
+      }
+    );
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..cdda2fc07f4b7c15b612c74c12a39dea6c106769
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,31 @@
+[build-system]
+build-backend = "flit_core.buildapi"
+requires = ["flit_core"]
+
+[project]
+name = "ut3_survival"
+version = "0.1.0"
+description = "Set of tools to stay sane while teaching at UT3"
+authors = [
+    {name = "Millian Poquet", email="millian.poquet@irit.fr"},
+]
+license = {file = "LICENSE"}
+requires-python = ">=3.9"
+
+keywords = ["ut3"]
+
+classifiers = [
+    "Topic :: Software Development",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3.9",
+]
+
+dependencies = [
+    "pandas>=1.3.0",
+    "xlrd>=2.0.1",
+    "click>=8.0.0",
+]
+
+[project.scripts]
+xls-to-csv = "ut3_survival.cmd.xls_to_csv:main"
diff --git a/ut3_survival/cmd/xls_to_csv.py b/ut3_survival/cmd/xls_to_csv.py
new file mode 100644
index 0000000000000000000000000000000000000000..42df7c4911fca2c9d09bb33a8a78344b23224a80
--- /dev/null
+++ b/ut3_survival/cmd/xls_to_csv.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+import sys
+
+import click
+import pandas
+
+from ut3_survival import realist
+
+@click.command()
+@click.argument('xls_file', required=True, nargs=-1)
+@click.option('-o', '--output', default=None, help='If set, parsed students are written as CSV to this file.')
+def main(xls_file, output):
+    students = realist.read_parse_several_xls(xls_file)
+
+    output_file = sys.stdout
+    if output is not None:
+        output_file = open(output, 'wt', encoding='utf-8')
+
+    students_df = realist.student_entry_list_to_df(students)
+    students_df.sort_values(by=['group', 'lastname', 'firstname', 'id'], inplace=True)
+    students_df.to_csv(output_file, index=False)
+
+if __name__ == "__main__":
+    main()
diff --git a/ut3_survival/realist.py b/ut3_survival/realist.py
new file mode 100755
index 0000000000000000000000000000000000000000..7d5a0fa0ad82031d3b2ed5558915e9d6542aadf7
--- /dev/null
+++ b/ut3_survival/realist.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+'''
+Student list parser from REALIST (usually accessed via SGCE).
+'''
+
+import sys
+from collections import namedtuple
+
+import pandas
+import xlrd
+
+student_columns = ['id', 'lastname', 'firstname', 'email', 'group']
+StudentEntry = namedtuple('StudentEntry', student_columns)
+
+def read_parse_xls(xls_filename: str) -> [StudentEntry]:
+    '''Read and parse a XLS file into a list of students.'''
+    students_xls = xlrd.open_workbook(xls_filename, logfile=sys.stderr)
+    group_names = students_xls.sheet_names()
+    group_names.pop(0) # first XLS sheet is useless
+    students = []
+
+    for group_name in group_names:
+        sheet = students_xls.sheet_by_name(group_name)
+        if sheet.cell_value(1,0) != "GROUPE : " + group_name:
+            raise AssertionError("SGCE's xls format has changed")
+
+        for row in range(3, sheet.nrows):
+            students.append(StudentEntry(
+                id=sheet.cell_value(row,0),
+                lastname=sheet.cell_value(row,1),
+                firstname=sheet.cell_value(row,2),
+                email=sheet.cell_value(row,3),
+                group=group_name,
+            ))
+    return students
+
+def read_parse_several_xls(xls_filenames: [str]) -> [StudentEntry]:
+    '''read_parse_xls wrapper when several files are to be used.'''
+    all_students = []
+    for xls_filename in xls_filenames:
+        try:
+            students = read_parse_xls(xls_filename)
+            all_students.extend(students)
+        except Exception as exception:
+            raise RuntimeError(f"could not read/parse xls file '{xls_filename}'") from exception
+    return all_students
+
+def student_entry_list_to_df(students: [StudentEntry]) -> pandas.DataFrame:
+    '''Create a DataFrame from a student list.'''
+    return pandas.DataFrame(students, columns=student_columns)