giovanni.ciatto@unibo.itCompiled on: 2026-11-06 — printable version
expect/actualExample code:
unibo-spe/multiplatform-example
Fuzzy concept
Insight:
The substratum upon which software applications run
Programming languages?
Runtimes
Software platform $\stackrel{\Delta}{=}$ anything having an API enabling the writing of applications, and the runtime supporting the execution of those applications
API $\equiv$ application programming interface(s) $\stackrel{\Delta}{=}$ a formal specification of the set of functionalities provided by a software (sub-)system for external usage, including their inputs, outputs, and environmental preconditions and effects
Runtime [system/environment] $\approx$ the set of computational resources backing the execution of a software (sub-)system
all possible public interfaces / classes / structures in an OOP module
all possible commands a CLI application accepts as input
all possible routes a Web service may accept HTTP requests on
any virtual machine or interpreter (JVM, CLR, CPython, V8)
any operating system (Win, Mac, Linux)
any Web service
The Java Virtual Machine (JVM)
.NET’s [pronounced “dot NET”] Common Language Runtime (CLR), cross-platform since .NET Core
Python 3
NodeJS (V8), and alternative JS runtimes such as Deno and Bun
Each browser may be considered as a platform per se
Native code for each OS + CPU architecture (e.g. via LLVM), and mobile platforms (Android, iOS)
…
standard libraries
predefined design decisions
organizational, stylistic, technical conventions
packaging conventions, import mechanisms, and software repositories
user communities
Object
synchronized instance methods lock the receivertoString() dispatches to the runtime classfinal by defaultRun the full example:
java -ea DesignDecisions.java
public class DesignDecisions {
static class Counter {
private int value;
public synchronized void increment() {
value++; // the receiver's monitor protects this update
}
@Override
public String toString() {
return "Counter(" + value + ")";
}
}
public static void main(String[] args) {
Counter counter = new Counter();
counter.increment();
Object object = counter; // every class extends Object
System.out.println(object); // virtual call: Counter(1)
int primitive = 1; // not an Object
Object boxed = primitive; // autoboxed to Integer
Organization: Maven directory layout
project/
├── pom.xml (or build.gradle.kts)
└── src/
├── main/java/it/unibo/spe/
│ └── CsvRow.java
└── test/java/it/unibo/spe/
└── CsvRowTest.java
CsvRow, getValues, valuesIterable enables the enhanced for loopCollection; variadic String... is a String[]Full code, including a main and assertions
package it.unibo.spe;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
public class CsvRow implements Iterable<String> {
private final List<String> values;
public CsvRow(Collection<String> values) {
this.values = List.copyOf(values);
}
public List<String> getValues() { // JavaBean getter
return values;
}
@Override
public Iterator<String> iterator() {
return values.iterator();
}
public static void main(String... args) { // String[]
var row = new CsvRow(List.of("Ada", "42"));
for (String value : row) System.out.println(value);
assert row.getValues().equals(List.of("Ada", "42"));
}
}
org.apache.commons.csvorg.apache.commons:commons-csv:1.14.1import org.apache.commons.csv.CSVFormat;java -cp ...)Resolve dependencies first; import classes in source code
The same JVM library can reach several communities through their preferred JVM language
struct, int, etc.) vs. reference types (class)
objectvirtual; specialize with overrideObject.ToString() is already virtualcounter.ValueComplete solution (.NET 10 SDK): from PlatformExamples/, run dotnet run --project CsvDemo
Point first = new Point { X = 1 };
Point copy = first; // struct: copy the value
copy.X = 2;
var counter = new Counter();
var alias = counter; // class: copy the reference
alias.Value = 2;
object boxed = first; // box a copy of the struct
Console.WriteLine($"{first.X}, {copy.X}"); // 1, 2
Console.WriteLine(counter); // Counter(2)
public struct Point
{
public int X { get; set; }
}
public class Counter
{
public int Value { get; set; }
public override string ToString() => $"Counter({Value})";
}
Organization: solution → projects → namespaces
PlatformExamples/
├── PlatformExamples.sln
└── CsvDemo/
├── CsvDemo.csproj
├── Program.cs
├── Point.cs
├── Counter.cs
├── Models/
│ └── CsvRow.cs
├── bin/ (generated assemblies)
└── obj/ (generated build files)
Models/ → CsvDemo.ModelsProgram.Main is the entry pointCsvRow, Count, GetEnumerator; interfaces start with IIEnumerable<T> enables foreach; params accepts variadic argumentsusing System.Collections;
using System.Collections.Generic;
namespace CsvDemo.Models;
public class CsvRow : IEnumerable<string>
{
private readonly string[] _values;
public CsvRow(params string[] values) // variadic arguments
{
_values = (string[])values.Clone();
}
public int Count => _values.Length; // property, not GetCount()
public IEnumerator<string> GetEnumerator() =>
((IEnumerable<string>)_values).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
.nupkg) → ZIP with assemblies, metadata and other assets.dll) → compiled types and metadata, targeting a frameworkdotnet add package CsvHelper records a reference in .csproj and restores itusing CsvHelper; allows the short name CsvReaderusing neither downloads a package nor loads an assembly; namespaces and assemblies are distinctNuGet resolves packages; assembly references make types available to the compiler
Frameworks have their own target platforms: e.g. WPF is Windows-only; Unity uses its own runtime tooling
str(row) → row.__str__()len(row) → row.__len__()def with the same name replaces the earlier bindingRun the full example:
python3 design_decisions.py
class Row:
def __init__(self, *values):
self.values = values
def __str__(self):
return ",".join(self.values)
def __len__(self):
return len(self.values)
row = Row("Ada", "42")
print(str(row)) # Ada,42: dispatches to __str__
print(len(row)) # 2: dispatches to __len__
print(isinstance(42, object)) # True, even for integers
Organization: src layout (flat layout is also common)
project/
├── pyproject.toml
├── src/csv_demo/
│ ├── __init__.py
│ └── csv_row.py
└── tests/
└── test_csv_row.py
CsvRow, join_values, four spaces*values → tuple; **metadata → dictionaryFull code, including assertions
class CsvRow: # PascalCase type name
def __init__(self, *values, **metadata):
self.values = values # tuple
self.metadata = metadata # dict
def __iter__(self):
return iter(self.values)
def join_values(row): # snake_case; duck typing
return ",".join(row) # any iterable of strings
row = CsvRow("Ada", "42", source="demo")
print(join_values(row)) # Ada,42
print(join_values(["Ada", "42"])) # same protocol
.py file; regular package → directory with __init__.py.whl) → installable ZIP (sources, optional native extensions, metadata).tar.gz) → must first be builtimport searches sys.path, including site-packagespip install scikit-learn → import sklearnpip installs a distribution; import loads a module
Scientific libraries reach users who expect Python objects and protocols, even when implemented in C / C++
row inherits toString from its prototypeasync / await
await suspends this async functionRun the full example:
node design-decisions.mjs
const rowPrototype = {
toString() { return this.values.join(','); }
};
const row = Object.create(rowPrototype);
row.values = ['Ada', '42'];
console.log(row.toString()); // inherited: Ada,42
const order = [];
async function readRow() {
order.push('start');
await Promise.resolve(row); // continuation runs later
order.push('resumed');
}
const pending = readRow();
order.push('caller'); // await did not block the caller
await pending;
console.log(order); // ['start', 'caller', 'resumed']
Organization: one possible layout
project/
├── package.json
├── src/
│ └── csv-row.mjs
└── test/
└── csv-row.test.mjs
CsvRow, joinValues; teams choose a linter / formatterSymbol.iterator).mjs explicitly selects ES modulesFull code, including assertions
export class CsvRow { // PascalCase type name
constructor(...values) { // rest arguments: Array
this.values = values;
}
[Symbol.iterator]() { // iterable protocol
return this.values[Symbol.iterator]();
}
}
function joinValues(row) { // camelCase; duck typing
return [...row].join(','); // any iterable
}
const row = new CsvRow('Ada', '42');
console.log(joinValues(row)); // Ada,42
console.log(joinValues(['Ada', '42'])); // same protocol
package.json + sources / assets, distributed as .tgznpm → dependency tool (also yarn, pnpm)npm install csv-parse → local dependency in node_modulesimport { parse } from 'csv-parse';const { parse } = require('csv-parse');exports / main; relative ESM imports include the extension, e.g. ./csv-row.mjsnpm installs packages;
import/requireloads their exported modules
Front-end code runs in the browser; Node often runs its build tools or server-side rendering
The choice of a platform impacts developers during:
the design phase
the implementation phase
the testing phase
the release phase
Abstraction gap $\approx$ the space between the problem and the pre-existing functionalities offered by a platform. Intuitively, the bigger the space, the more effort is required to build the solution
Developers write solutions by leveraging the API of the platform
… as well as the API of any third-party library available for that platform
Test suites are a “project in the project”
One may test the system against as many versions as possible of the underlying platforms
One may test the system against as many OSs as possible
Both are typically automated via CI build matrices (cf. CI lecture)
Release $\approx$ publishing some packaged software system onto a repository, hence enabling its import and exploitation
Packaging systems are platform-specific…
Repositories are platform-specific…
… release is therefore platform specific (cf. version selection and CI)
Choice is commonly driven by design / technical decisions
However, choosing the platform is a business decision as well
Business decision: which user communities to target?
Coherence is key for success in platform selection
Researchers may act as software developers
to elaborate data
to create in-silico experiments
to study software systems
to create software tools improving their research
to create software tools for the community
Research institutions are not software houses
Personnel can only dedicate a fraction of their time to development
Most software artifacts are disposable (data elaboration, in-silico experiments, personal tools)
Development efforts are discontinuous
Development teams are small
Software is commonly a means, not an objective
Commitment to software development is:
Research-oriented software development should maximise audience and impact, while minimising development and maintenance effort
Science requires reproducibility
The wider the community, the wider the impact of community-driven research software
Minimising effort can be done by improving efficiency
Choosing the right community / platform is strategic, especially for research-oriented software
The abstraction gap is likely lower
More third party libraries are likely available
The potential audience is wider
Easier to find support / help in case of issues
More likely that third-party issues are fixed in a timely manner
Silos (in IT) are software components / systems / ecosystems having poor external interoperability (i.e. software from silo A hardly interoperates with software from silo B)
Platforms are (pretty wide) software silos
Examples:
Research communities in CS / AI may overlap with platform (or language) communities:
What about inter-community research efforts?
Multi-platform programming is an enabler for inter-community research
Ideal goal:
let the same software tool run on multiple platforms
More reasonable goal:
create multiple artifacts, one for each supported platform, sharing the same design and functioning
Practical goal:
design and write the software once, then port it to several platforms
Write once, build anywhere
Write first, wrap elsewhere
Assumptions:
Workflow:
Let N be the number of supported platforms
Platform-agnostic functionalities require effort which is independent of N
Platform-specific functionalities require effort proportional to N
Better to minimise platform-specific code
Relevant questions:
The abstraction gap of the common code is at least as wide as the one of the platform having the widest abstraction gap (the common std-lib is roughly the intersection of what all platforms offer)
Whenever a new functionality needs to be developed:
Try to realise it with common std-lib only
If not possible, try to maximise the portion of platform-agnostic code
For each functionality which cannot be realised as purely platform-agnostic:
N times, one per target platformAssumptions:
Workflow:
Fully implement, test, and deploy the software for the main platform
For all other platforms:
Upload platform-specific artifacts on platform-specific repositories
Let N be the number of supported platforms
Clear separation of API code from implementation code is quintessential
The effort required for writing API code is virtually the same on all platforms
Global effort is still linear in N, but with a much smaller constant
N timesThe i-th platform’s wrapper code will only call the main platform’s API code
Relevant questions:
JetBrains-made modern programming language
Gaining momentum since Google adopted it as the official Android language (2017)
Clearly inspired by a mixture of Java, C#, Scala, and Groovy
Born in industry, for the industry
Acts as the “super language” supporting the “write once, build anywhere” approach
Reference: Kotlin Multiplatform DSL reference
JVM
kotlin { jvmToolchain(17) }JavaScript (JS)
WebAssembly (Wasm)
wasmJs (browsers, Node) and wasmWasi (WASI runtimes)Android
Native
.klibs) for any target, but final binaries for Apple targets require macOSReference: Kotlin/Native target support
Targets come in tiers, i.e. levels of testing on JetBrains’ CI:
macosArm64, iosArm64, iosSimulatorArm64linuxX64, linuxArm64, watchosArm64, tvosArm64mingwX64 (Windows), androidNative*macosX64 since Kotlin 2.3.20)Windows via MinGW, Linux
iOS, macOS, watchOS, tvOS
Android Native (androidNative*)
android targetBuilding a multi-platform Kotlin project is supported via Gradle (cf. build automation)
Gradle is a build automation + dependency management system
Gradle simultaneously enables & constrains the multi-platform workflow
Kotlin enforces strong segregation of the platform-agnostic and platform-specific parts
common main code: platform-agnostic code which only depends on:
common test code: platform-agnostic test code which only depends on:
kotlin.test or Kotest)T (e.g. jvm, js, etc.)
T-specific main code: main Kotlin code targeting the T platform, depending on:
T-specific standard libraryT-specific third-party librariesT-specific test code: test Kotlin code targeting the T platform, depending on:
T-specific main codeT-specific test libraries<root>/
│
├── build.gradle.kts # multi-platform build description
├── settings.gradle.kts # root project settings
│
└── src/
├── commonMain/
│ └── kotlin/ # platform-agnostic Kotlin code here
│
├── commonTest/
│ └── kotlin/ # platform-agnostic test code written in Kotlin here
│
├── jvmMain/
│ ├── java/ # JVM-specific Java code here
│ ├── kotlin/ # JVM-specific Kotlin code here
│ └── resources/ # JVM-specific resources here
├── jvmTest/
│ ├── java/ # JVM-specific test code written in Java here
│ ├── kotlin/ # JVM-specific test code written in Kotlin here
│ └── resources/ # JVM-specific test resources here
│
├── jsMain/
│ └── kotlin/ # JS-specific Kotlin code here
├── jsTest/
│ └── kotlin/ # JS-specific test code written in Kotlin here
│
├── <TARGET>Main/
│ └── kotlin/ # <TARGET>-specific Kotlin code here
└── <TARGET>Test/
└── kotlin/ # <TARGET>-specific test code written in Kotlin here
Defines several aspects of the project:
which version of the Kotlin compiler to adopt
plugins {
kotlin("multiplatform") version "2.4.21" // defines plugin and compiler version
}
gradle/libs.versions.toml)which repositories Gradle should use when looking for dependencies
repositories {
mavenCentral() // use MCR for downloading dependencies (recommended)
// other custom repositories here (discouraged)
}
Defines several aspects of the project:
which platforms to target (reference here)
kotlin {
jvmToolchain(17) // JDK used to compile (and run tests on) the JVM target
// declares JVM as target (Java sources in src/jvmMain/java are supported out of the box)
jvm()
// declares JavaScript as target
js {
useEsModules() // generate ES modules (or useCommonJs())
binaries.library() // generate a reusable npm package (or binaries.executable())
// the target will consist of a Node project (with NodeJS's stdlib)
nodejs {
testTask { /* configure Node's testing frameworks */ }
}
// alternatively, or additionally to nodejs:
browser { /* ... */ }
}
// other targets here
}
other admissible targets:
wasmJs, wasmWasi, androidkotlin {
sourceSets {
// the Kotlin std-lib is added automatically to all source sets
commonMain.dependencies {
implementation("group.of:multiplatform-library:X.Y.Z") // or api
}
commonTest.dependencies {
// kotlin.test, automatically resolved to the right variant for each target
implementation(kotlin("test"))
}
jvmMain.dependencies {
implementation("group.of:jvm-library:X.Y.Z") // or api
}
jsMain.dependencies {
implementation(npm("npm-module", "X.Y.Z")) // lookup on https://www.npmjs.com
}
}
}
jvmTest, jsTest) can declare their own dependencies tooconfigure Kotlin compiler options, for all targets (or per target, e.g. jvm { compilerOptions { ... } })
kotlin {
compilerOptions {
// source compatibility with the specified version of Kotlin
languageVersion = KotlinVersion.KOTLIN_2_3
// allows using declarations only from the specified version of Kotlin bundled libraries
apiVersion = KotlinVersion.KOTLIN_2_3
// allows using the specified opt-in API
optIn.add("kotlin.ExperimentalUnsignedTypes") // annotation FQ-name
// enables progressive mode
progressiveMode = true // false by default
// turns warnings into errors
allWarningsAsErrors = true
}
}
details about:
plugins {
kotlin("multiplatform") version "2.4.21"
}
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(17)
jvm()
js {
useEsModules()
binaries.library()
nodejs()
}
compilerOptions {
allWarningsAsErrors = true
}
sourceSets {
commonMain.dependencies {
implementation("group.of:multiplatform-library:X.Y.Z")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
jvmMain.dependencies {
implementation("group.of:jvm-library:X.Y.Z")
}
jsMain.dependencies {
implementation(npm("npm-module", "X.Y.Z"))
}
}
}
Let T denote the target name (e.g. jvm, js, etc.); recall Gradle’s lifecycle tasks from the build automation lecture
<T>MainClasses compiles the main code for platform T
jvmMainClasses, jsMainClasses<T>TestClasses compiles the test code for platform T
jvmTestClasses, jsTestClasses<T>Jar compiles the main code for platform T and produces a JAR out of it
jvmJar, jsJar<T>MainClasses, but NOT <T>TestClasses<T>Test executes tests for platform T
jvmTest, jsTest<T>MainClasses, AND on <T>TestClassesjsTest aggregates jsNodeTest and jsBrowserTest (depending on the enabled sub-targets)jsProductionExecutableCompileSync (resp. jsProductionLibraryCompileSync) compiles the JS main code into a Node project
js target to be enabledbinaries.executable() (resp. binaries.library()) configuration to be enabledassemble creates all JARs / klibs (hence compiling the main code for all platforms)
allTests executes tests for all platforms, and creates an aggregated report
check like allTests but it may also include other checks (e.g. style) if configured
build $\approx$ check + assemble
Ordinary Kotlin sources
When in common:
Common platform)expect keyword@JvmStatic, @JsName, etc.Let T denote some target platform
When in T-specific source sets
T-specific std-libT-specific Kotlin librariesT-specific librariesactual keywordEach platform T may allow for specific keywords
external modifierexpect/actual mechanism for specialising common APIDeclaring an expected function / type declaration in common code…
… enforces the existence of a corresponding actual definition for all platforms
Draw a platform-agnostic design for your domain entities
expect keyword to declare platform-agnostic factoriesAssess the abstraction gap, for each target platform
For each target platform:
actual keyword to implement platform-specific factoriesCSV (comma-separated values) files, e.g.:
# HEADER_1, HEADER_2, HEADER_3, ...
# character '#' denotes the beginning of a single-line comment
# first line conventionally denotes column names (headers)
field1, field2, field3, ...
# character ',' acts as field separator
"field with spaces", "another field, with comma", "yet another field", ...
# character '"' acts as field delimiter
# other characters may be used to denote comments, separators, or delimiters
JVM and JS std-lib do not provide direct support for CSV
Requirements:
We’ll follow a domain-driven approach:
Domain entities (can be realised as common code):
Table: in-memory representation of a CSV fileRow: generic row in a CSV fileHeader: special row containing the names of the columnsRecord: special row containing the values of a line in a CSV fileMain functionalities (some of which require platform-specific code):
Table programmaticallyTableTableTable into a CSV fileFull code: unibo-spe/multiplatform-example (module csv-core)
top to bottom direction
package "kotlin" {
interface Iterable<T>
}
package "io github gciatto csv" {
interface Row {
+ get(index: Int): String
+ size: Int
}
Row -up-|> Iterable: //T// = String
interface Header {
+ columns: List<String>
+ contains(column: String): Boolean
+ indexOf(column: String): Int
}
Row <|-- Header
interface Record {
+ header: Header
+ values: List<String>
+ contains(value: String): Boolean
+ get(column: String): String
}
Row <|-- Record
Record "*" *-right- "1" Header
interface Table {
+ header: Header
+ records: List<Record>
+ get(row: Int): Row
+ size: Int
}
Table "*" *-u- "1" Header
Table "*" o-u- "*" Record
Table -u----|> Iterable: //T// = Row
}
Row interface/** Represents a single row in a CSV file. */
@JsExport
interface Row : Iterable<String> {
/** Gets the value of the field at the given index. */
@JsName("getByIndex")
operator fun get(index: Int): String
/** Gets the number of fields in this row. */
@JsName("size")
val size: Int
}@JsExport and @JsName annotations for now, they are explained laterHeader interface/** Represents the header of a CSV file, i.e. a special row containing the names of the columns. */
@JsExport
interface Header : Row {
/** Gets the names of the columns. */
@JsName("columns")
val columns: List<String>
/** Checks whether the given column name is present in this header. */
@JsName("contains")
operator fun contains(column: String): Boolean
/** Gets the index of the given column name (-1 if missing). */
@JsName("indexOf")
fun indexOf(column: String): Int
}Record interface/** Represents a record in a CSV file. */
@JsExport
interface Record : Row {
/** Gets the header of this record (useful to know column names). */
@JsName("header")
val header: Header
/** Gets the values of the fields in this record. */
@JsName("values")
val values: List<String>
/** Checks whether the given value is present in this record. */
@JsName("contains")
operator fun contains(value: String): Boolean
/** Gets the value of the field in the given column. */
@JsName("getByName")
operator fun get(column: String): String
}Table interface/** Represents a table, i.e. an in-memory representation of a CSV file. */
@JsExport
interface Table : Iterable<Row> {
/** Gets the header of this table (useful to know column names). */
@JsName("header")
val header: Header
/** Gets the records in this table. */
@JsName("records")
val records: List<Record>
/** Gets the row at the given index (0 is the header). */
@JsName("get")
operator fun get(row: Int): Row
/** Gets the number of rows in this table (header included). */
@JsName("size")
val size: Int
}<p>top to bottom direction</p>
<p>package “io github gciatto csv <strong>impl</strong>” {
interface Row</p>
<pre><code>abstract class AbstractRow {
- values: List<String>
# constructor(values: List<String>)
# toString(type: String?): String
}
Row <|-d- AbstractRow
interface Header
class DefaultHeader {
- indexesByName: Map<String, Int>
+ constructor(columns: Iterable<String>)
}
Row <|-d- Header
Header <|-d- DefaultHeader
AbstractRow <|-d- DefaultHeader
interface Record
class DefaultRecord {
+ constructor(header: Header, values: Iterable<String>)
}
Row <|-d- Record
Record <|-- DefaultRecord
AbstractRow <|-d- DefaultRecord
class DefaultTable {
+ constructor(header: Header, records: Iterable<Record>)
}
interface Table
Table <|-- DefaultTable
</code></pre>
<p>}</p>
AbstractRow classinternal abstract class AbstractRow(protected open val values: List<String>) : Row {
override val size: Int
get() = values.size
override fun get(index: Int): String = values[index]
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as AbstractRow
return values == other.values
}
override fun hashCode(): Int = values.hashCode()
protected fun toString(type: String?): String {
var prefix = ""
var suffix = ""
if (type != null) {
prefix = "$type("
suffix = ")"
}
return values.joinToString(", ", prefix, suffix) { "\"$it\"" }
}
override fun toString(): String = toString("Row")
override fun iterator(): Iterator<String> = values.iterator()
}Row interface: toString(type) renders rows as <type>("field1", "field2", ...)DefaultHeader classinternal class DefaultHeader(columns: Iterable<String>) : Header, AbstractRow(columns.toList()) {
private val indexesByName by lazy {
values.mapIndexed { index, name -> name to index }.toMap()
}
override val columns: List<String>
get() = values
override fun contains(column: String): Boolean = column in indexesByName.keys
override fun indexOf(column: String): Int = indexesByName[column] ?: -1
override fun iterator(): Iterator<String> = values.iterator()
override fun toString(): String = toString("Header")
}indexesByName is a lazily-computed cache of column indexes, for faster lookupDefaultRecord classinternal class DefaultRecord(
override val header: Header,
values: Iterable<String>
) : Record, AbstractRow(values.toList()) {
init {
require(header.size == super.values.size) {
"Inconsistent amount of values (${super.values.size}) w.r.t. header size (${header.size})"
}
}
override fun contains(value: String): Boolean = values.contains(value)
override val values: List<String>
get() = super.values
override fun get(column: String): String =
header.indexOf(column).takeIf { it in 0..< size }?.let { values[it] }
?: throw NoSuchElementException("No such column: $column")
override fun toString(): String = toString("Record")
}DefaultTable classinternal class DefaultTable(override val header: Header, records: Iterable<Record>) : Table {
// Eager, defensive copy of the records
override val records: List<Record> = records.toList()
override fun get(row: Int): Row = if (row == 0) header else records[row - 1]
override val size: Int
get() = records.size + 1
override fun iterator(): Iterator<Row> = (sequenceOf(header) + records.asSequence()).iterator()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DefaultTable) return false
if (header != other.header) return false
if (records != other.records) return false
return true
}
override fun hashCode(): Int {
var result = header.hashCode()
result = 31 * result + records.hashCode()
return result
}
override fun toString(): String = joinToString(", ", "Table(", ")")
}To enforce separation between API and implementation code, it’s better:
Convention in Kotlin is to create factory methods as package-level functions
io/github/gciatto/csv/Csv.kt fileFactory methods may be named after the concept they create: <concept>Of(args...)
headerOf(columns...), recordOf(header, values...), etc.Class diagram:
@startuml
skinparam monochrome false
class Csv{
+ headerOf(columns): Header
+ anonymousHeader(size: Int): Header
..
+ recordOf(header, values): Record
..
+ tableOf(header, records): Table
+ tableOf(rows): Table
}
@enduml
Csv.kt file (pt. 1)Factories for headers (possibly anonymous, i.e. with columns named after their index), records, and tables:
fun headerOf(columns: Iterable<String>): Header = DefaultHeader(columns)
@JsExport
fun headerOf(vararg columns: String): Header = headerOf(columns.asIterable())
fun headerOf(columns: Sequence<String>): Header = headerOf(columns.asIterable())
@JsExport
fun anonymousHeader(size: Int): Header = headerOf((0 ..< size).map { it.toString() })
fun recordOf(header: Header, columns: Iterable<String>): Record = DefaultRecord(header, columns)
@JsExport
fun recordOf(header: Header, vararg columns: String): Record = recordOf(header, columns.asIterable())
fun recordOf(header: Header, columns: Sequence<String>): Record = recordOf(header, columns.asIterable())
fun tableOf(header: Header, records: Iterable<Record>): Table = DefaultTable(header, records)
@JsExport
@JsName("table")
fun tableOf(header: Header, vararg records: Record): Table = tableOf(header, records.asIterable())
fun tableOf(header: Header, records: Sequence<Record>): Table = tableOf(header, records.asIterable())Iterable, Sequence, and vararg argumentsCsv.kt file (pt. 2)Table creation from rows (anonymous header if none is provided):
fun tableOf(rows: Iterable<Row>): Table {
val records = mutableListOf<Record>()
var header: Header? = null
for (row in rows) {
when (row) {
is Header -> header = row
is Record -> records.add(row)
}
}
require(header != null || records.isNotEmpty())
return tableOf(header ?: anonymousHeader(records[0].size), records)
}
@JsExport
@JsName("tableOf")
fun tableOf(vararg rows: Row): Table = tableOf(rows.asIterable())
fun tableOf(rows: Sequence<Row>): Table = tableOf(rows.asIterable())Dummy instances object, in Tables.kt:
object Tables {
val irisShortHeader = headerOf("sepal_length", "sepal_width", "petal_length", "petal_width", "class")
val irisLongHeader = headerOf(
"sepal length in cm",
"sepal width, in cm",
"petal length",
"petal width",
"class"
)
fun iris(header: Header): Table = tableOf(
header,
recordOf(header, "5.1", "3.5", "1.4", "0.2", "Iris-setosa"),
recordOf(header, "4.9", "3.0", "1.4", "0.2", "Iris-setosa"),
recordOf(header, "4.7", "3.2", "1.3", "0.2", "Iris-setosa")
)
}Some basic tests in TestCSV.kt (bad way of writing test methods, don’t do this at home):
@Test
fun recordBasics() {
val record = Tables.iris(Tables.irisShortHeader).records[0]
assertEquals("5.1", record[0])
assertEquals("5.1", record["sepal_length"])
assertEquals("3.5", record[1])
assertEquals("3.5", record["sepal_width"])
assertEquals("1.4", record[2])
assertEquals("1.4", record["petal_length"])
assertEquals("0.2", record[3])
assertEquals("0.2", record["petal_width"])
assertEquals("Iris-setosa", record[4])
assertEquals("Iris-setosa", record["class"])
assertFailsWith<IndexOutOfBoundsException> { record[5] }
assertFailsWith<NoSuchElementException> { record["missing"] }
}allTests (also try to run tests for specific platforms, e.g. jvmTest or jsTest)We designed a set of domain entities
Row, Header, Record, TableWe implemented the domain entities as common code
We wrote some unit tests for the domain entities
Let’s focus now on more complex functionalities
TableTable into a CSV fileI/O is platform-specific, hence we need platform-specific code
I/O functionalities are supported by fairly different APIs in the JVM and JS
java.io package vs. JS’ fs moduleDo we need to rewrite the same business logic twice? (once for JVM, once for JS)
Let’s try to decompose the problem as follows:
Table: file $\rightarrow$ string(s) $\rightarrow$ TableTable as CSV file: Table $\rightarrow$ string(s) $\rightarrow$ fileRemarks:
Table” part is platform-agnostic
Configuration: set of characters to be used to parse / represent CSV files
Formatter: converts Rows into strings, according to some Configuration
Parser: converts some source into a Table, according to some Configuration
@startuml
skinparam monochrome false
package "io github gciatto csv" {
class Configuration {
+ separator: Char
+ delimiter: Char
+ comment: Char
--
+ isComment(string: String): Boolean
+ isRecord(string: String): Boolean
+ isHeader(string: String): Boolean
..
+ getComment(string: String): String?
+ getFields(string: String): List<String>
+ getColumnNames(string: String): List<String>
}
interface Formatter {
+ source: Iterable<Row>
+ configuration: Configuration
+ format(): Iterable<String>
}
interface Parser {
+ source: Any
+ configuration: Configuration
+ parse(): Iterable<Row>
}
Formatter "1" o-- "1" Configuration
Parser "1" o-- "1" Configuration
}
@enduml
Configuration classdata class Configuration(val separator: Char, val delimiter: Char, val comment: Char) {
// Checks whether the given string is a comment (i.e. starts with the comment character).
fun isComment(string: String): Boolean = TODO()
// Gets the content of the comment line (i.e. removes the initial comment character).
fun getComment(string: String): String? = TODO()
// Checks whether the given string is a record (i.e. it contains at least one field)
fun isRecord(string: String): Boolean = TODO()
// Retrieves the fields in the given record (i.e. splits the string at the separator character)
fun getFields(string: String): List<String> = TODO()
// Checks whether the given string is a header (i.e. a comment whose content is a record)
fun isHeader(string: String): Boolean = TODO()
// Retrieves the column names in the given header
fun getColumnNames(string: String): List<String> = TODO()
}
Formatter interface/** Converts rows into strings, according to some [Configuration]. */
@JsExport
interface Formatter {
/** The source of this formatter (i.e. the rows to be formatted). */
@JsName("source")
val source: Iterable<Row>
/** The configuration of this formatter (i.e. the characters to be used). */
@JsName("configuration")
val configuration: Configuration
/** Formats the source into a sequence of strings (one for each row in the source). */
@JsName("format")
fun format(): Iterable<String>
}Parser interface/** Converts some source into rows, according to some [Configuration]. */
@JsExport
interface Parser {
/** The source to be parsed by this parser (must be interpretable as string). */
@JsName("source")
val source: Any
/** The configuration of this parser (i.e. the characters to be used). */
@JsName("configuration")
val configuration: Configuration
/** Parses the source into a sequence of rows (one for each row in the source). */
@JsName("parse")
fun parse(): Iterable<Row>
}@startuml
skinparam monochrome false
package "io github gciatto csv impl" {
class Configuration
interface Formatter
interface Parser
DefaultFormatter "1" o-- "1" Configuration
AbstractParser "1" o-- "1" Configuration
class DefaultFormatter {
+ constructor(source: Iterable<Row>, configuration: Configuration)
- formatRow(row: Row): String
- formatAsHeader(row: Row): String
- formatAsRecord(row: Row): String
}
Formatter <|-- DefaultFormatter
abstract class AbstractParser {
# constructor(source: Any, configuration: Configuration)
# beforeParsing()
# afterParsing()
# {abstract} sourceAsLines(): Sequence<String>
}
Parser <|-- AbstractParser
class StringParser {
+ source: String
+ constructor(source: String, configuration: Configuration)
+ sourceAsLines(): Sequence<String>
}
AbstractParser <|-- StringParser
}
@enduml
DefaultFormatter classinternal class DefaultFormatter(
override val source: Iterable<Row>,
override val configuration: Configuration
) : Formatter {
override fun format(): Iterable<String> = source.asSequence().map(this::formatRow).asIterable()
private fun formatAsHeader(header: Header): String = "${configuration.comment} ${formatAsRecord(header)}"
private fun formatAsRecord(row: Row): String =
row.joinToString("${configuration.separator} ") {
val delimiter = configuration.delimiter
"$delimiter$it$delimiter"
}
private fun formatRow(row: Row): String = when (row) {
is Header -> formatAsHeader(row)
else -> formatAsRecord(row)
}
}format lazily converts each row into a string: headers are prefixed by the comment character, fields are wrapped by the delimiter characterAbstractParser class (pt. 1)internal abstract class AbstractParser(
override val source: Any,
override val configuration: Configuration
) : Parser {
// Hooks to be overridden by sub-classes to initialise / finalise parsing (e.g. open / close files)
protected open fun beforeParsing() = Unit
protected open fun afterParsing() = Unit
// Template method that splits the source into a sequence of strings (one per line)
protected abstract fun sourceAsLines(): Sequence<String>beforeParsing / afterParsing are hooks, sourceAsLines is the abstract step of a template methodAbstractParser class (pt. 2) override fun parse(): Iterable<Row> = sequence {
beforeParsing()
try {
var header: Header? = null
for ((i, line) in sourceAsLines().withIndex()) {
if (line.isBlank()) {
continue
} else if (configuration.isHeader(line)) {
if (header == null) {
header = headerOf(configuration.getColumnNames(line))
yield(header)
}
} else if (configuration.isComment(line)) {
continue
} else if (configuration.isRecord(line)) {
val fields = configuration.getFields(line)
if (header == null) {
header = anonymousHeader(fields.size)
yield(header)
}
try {
yield(recordOf(header, fields))
} catch (e: IllegalArgumentException) {
throw IllegalStateException("Invalid CSV at line ${i + 1}: $line", e)
}
} else {
error("Invalid CSV at line ${i + 1}: $line")
}
}
} finally {
afterParsing() // executed even in case of errors
}
}.asIterable()sequence { ... yield(...) } produces rows lazily, one at a timefinally ensures afterParsing() is called upon errors too (not if the consumer stops iterating early)StringParser classinternal class StringParser(override val source: String, configuration: Configuration) : AbstractParser(source, configuration) {
override fun sourceAsLines(): Sequence<String> = source.lineSequence()
}Additions to the Csv.kt file:
const val DEFAULT_SEPARATOR = ','
const val DEFAULT_DELIMITER = '"'
const val DEFAULT_COMMENT = '#'@JsExport
fun Iterable<Row>.formatAsCSV(
separator: Char = DEFAULT_SEPARATOR,
delimiter: Char = DEFAULT_DELIMITER,
comment: Char = DEFAULT_COMMENT
): String = DefaultFormatter(this, Configuration(separator, delimiter, comment)).format().joinToString("\n")
@JsExport
fun String.parseAsCSV(
separator: Char = DEFAULT_SEPARATOR,
delimiter: Char = DEFAULT_DELIMITER,
comment: Char = DEFAULT_COMMENT
): Table = StringParser(this, Configuration(separator, delimiter, comment)).parse().let(::tableOf)formatAsCSV converts any container of rows into a CSV string, parseAsCSV parses a CSV string into a tableDummy instances for testing (other constants in CsvStrings.kt):
object CsvStrings {
val iris: String = """
|# sepal_length, sepal_width, petal_length, petal_width, class
|5.1,3.5,1.4,0.2,Iris-setosa
|4.9,3.0,1.4,0.2,Iris-setosa
|4.7,3.2,1.3,0.2,Iris-setosa
""".trimMargin()
val irisWellFormatted: String = """
|# "sepal_length", "sepal_width", "petal_length", "petal_width", "class"
|"5.1", "3.5", "1.4", "0.2", "Iris-setosa"
|"4.9", "3.0", "1.4", "0.2", "Iris-setosa"
|"4.7", "3.2", "1.3", "0.2", "Iris-setosa"
""".trimMargin()Tests involving parsing be like:
@Test
fun parsingFromCleanString() {
val iris: Table = CsvStrings.iris.parseAsCSV()
assertEquals(
expected = Tables.iris(Tables.irisShortHeader),
actual = iris
)
}Tests involving formatting be like:
@Test
fun formattingToString() {
val iris: Table = Tables.iris(Tables.irisShortHeader)
assertEquals(
expected = CsvStrings.irisWellFormatted,
actual = iris.formatAsCSV()
)
}Csv.kt file (reading and parsing a CSV file from the given path):expect fun parseCsvFile(
path: String,
separator: Char = DEFAULT_SEPARATOR,
delimiter: Char = DEFAULT_DELIMITER,
comment: Char = DEFAULT_COMMENT
): Tableexpect keyword, and the lack of function bodyString as a platform-agnostic representation of paths
I/O (over textual files) is mainly supported by means of the following classes:
Buffered readers support reading a file line-by-line
On Kotlin/JVM, Java’s std-lib is available as Kotlin’s std-lib
We may simply create a new sub-type of AbstractParser
Files as sourcesBufferedReader behind the scenes to read files line-by-lineIn the jvmMain source set
A JVM-specific parser, FileParser.kt:
internal class FileParser(override val source: File, configuration: Configuration) : AbstractParser(source, configuration) {
private lateinit var reader: BufferedReader
override fun beforeParsing() {
reader = source.bufferedReader()
}
override fun afterParsing() {
reader.close()
}
override fun sourceAsLines(): Sequence<String> = reader.lines().asSequence()
}Filelateinit reader is opened before parsing, and closed afterwardsThe Csv.jvm.kt file:
@file:JvmName("CsvJvm")
package io.github.gciatto.csv
import java.io.File
import kotlin.jvm.JvmName
actual fun parseCsvFile(
path: String,
separator: Char,
delimiter: Char,
comment: Char
): Table = FileParser(File(path), Configuration(separator, delimiter, comment)).parse().let(::tableOf)parseCsvFile is implemented on the JVMactual keyword, and the presence of a function bodyFileParser in the function body
FileParser is an internal class filling the abstraction gap on the JVM@file:JvmName is explained laterI/O (over textual files) is mainly supported by means of the following things:
These functions support reading / writing a file in one shot
On Kotlin/JS, Node’s std-lib is not directly available
external declarations)To-do list for Kotlin/JS:
external declarations for the Node’s std-lib functions to be usedStringParser (after reading the whole file)In the jsMain source set
First, let’s create the NodeFs.kt file (containing external declarations for the fs module):
@file:JsModule("fs")
package io.github.gciatto.csv
external fun readFileSync(path: String, options: dynamic = definedExternally): String
external fun writeFileSync(file: String, data: String, options: dynamic = definedExternally)readFileSync and writeFileSync@file:JsModule("fs") annotation instructs the compiler that the declarations of this file come from the fs moduleexternal declarations are Kotlin signatures of JS functionsdynamic is a special type that can be used to represent any JS object
external stuffdefinedExternally is stating that a parameter is optional (default value is defined in JS)Then, let’s create the Csv.js.kt file containing:
@JsExport
actual fun parseCsvFile(
path: String,
separator: Char,
delimiter: Char,
comment: Char
): Table = readFileSync(path, js("{encoding: 'utf8'}")).parseAsCSV(separator, delimiter, comment)parseCsvFile is implemented on JSactual keyword, and the presence of a function bodyreadFileSync to read a file as a string in one shotTable (namely, parseAsCSV)@JsExport makes the function callable from JS code (see later)js("...") magic function
readFileSyncWe may test our CSV library in common-code
The test suite may:
The hard part is step 1: two platform-specific steps
commonTest (creates a temporary file with the given name and content, and returns its path):internal fun String.splitExtension(): Pair<String, String> {
val splitted = split(".")
return if (splitted.size > 1) {
splitted.subList(0, splitted.lastIndex).joinToString(".") to splitted.last()
} else {
this to ""
}
}
expect fun createTempFile(name: String, content: String): Stringnotice the expected function
file Utils.jvm.kt in jvmTest:
actual fun createTempFile(name: String, content: String): String {
val (n, e) = name.splitExtension()
val file = File.createTempFile(n, ".$e")
file.writeText(content)
return file.absolutePath
}File.createTempFilejsMain (external declaration for the os module):@file:JsModule("os")
package io.github.gciatto.csv
external fun tmpdir(): StringjsTest:import kotlin.random.Random
private val tmpDirectory by lazy {
tmpdir()
}
actual fun createTempFile(name: String, content: String): String {
val (n, e) = name.splitExtension()
val tag = Random.nextLong().toULong()
val path = "$tmpDirectory/$n-$tag.$e"
writeFileSync(path, content)
return path
}os.tmpdirkotlin.random.Random is part of the common std-lib, so no JS-specific code is needed for random numberscommonTest (paths of temporary files, lazily created upon first usage, containing the strings in CsvStrings):object CsvFiles {
val iris: String by lazy {
createTempFile("iris.csv", CsvStrings.iris)
}
val irisWellFormatted: String by lazy {
createTempFile("irisWellFormatted.csv", CsvStrings.irisWellFormatted)
} @Test
fun testParseIris() {
val parsedFromString = CsvStrings.iris.parseAsCSV()
val readFromFile = parseCsvFile(CsvFiles.iris)
assertEquals(parsedFromString, readFromFile)
}Kotlin multi-platform libraries are published as a set of artifacts (one per target, plus Gradle module metadata)
The jvmMain source set is compiled into a JVM-compliant JAR
*Jar or assemble tasksThe jsMain source set is compiled into either
.klib), enabling importing the project as a dependency in Kotlin/JS projects
*Jar or assemble tasksjsProductionExecutableCompileSync (or jsProductionLibraryCompileSync) taskTask for JVM-only compilation of re-usable packages: jvmJar
Effect:
$PROJ_DIR/build/libs/$PROJ_NAME-jvm-$PROJ_VERSION.jarThe JAR does not contain dependencies
Ad-hoc Gradle plugins or code are needed to create a fat JAR
shadowJarTask for JS-only compilation of re-usable packages: jsProductionLibraryCompileSync (with binaries.library())
jsProductionExecutableCompileSync (with binaries.executable())Effect:
$ROOT_PROJ_DIR/build/js/packages/$ROOT_PROJ_NAME-$PROJ_NAME:
.mjs files, when using ES modules)package.json filemainSupport for packing as npm package via the npm-publish Gradle plugin
Kotlin code can be called from the target platforms’ main languages
Understanding the mapping between Kotlin and other languages is key
How are Kotlin’s syntactical categories mapped to other platforms/languages?
class MyClass {}
interface MyType {}
public class MyClass {}
public interface MyType {}
Int $\leftrightarrow$ int / Integer, Short $\leftrightarrow$ short / Short, etc.val x: Int = 0
val y: Int? = null
int x = 0;
Integer y = null;
Any $\rightarrow$ Object, kotlin.collections.List $\rightarrow$ java.util.List, etc.val x: Any = "ciao"
val y: kotlin.collections.List<Int> = listOf(1, 2, 3)
val z: kotlin.collections.MutableList<String> = mutableListOf("a", "b", "c")
Object x = "ciao";
java.util.List<Integer> y = java.util.Arrays.asList(1, 2, 3);
java.util.List<String> z = java.util.List.of("a", "b", "c");
interface MyType {
val x: Int
var y: String
}
public interface MyType {
int getX();
String getY(); void setY(String y);
}
JvmField annotation is adoptedimport kotlin.jvm.JvmField
class MyType {
@JvmField
val x: Int = 0
@JvmField
var y: String = ""
}
public class MyType {
public final int x = 0;
public String y = "";
}
X.kt are mapped to static methods of class XKt// file MyFile.kt
fun f() {}
public final class MyFileKt {
public static void f() {}
}
JvmName annotation is exploited (e.g. @file:JvmName("Csv") in our Csv.kt)@file:JvmName("MyModule")
import kotlin.jvm.JvmName
fun f() {}
public final class MyModule {
public static void f() {}
}
object X is mapped to Java class X with
INSTANCE to accessobject MySingleton {}
public final class MySingleton {
private MySingleton() {}
public static final MySingleton INSTANCE = new MySingleton();
}
X’s companion object is mapped to public static final field named Companion on class Xclass MyType private constructor() {
companion object {
fun of(): MyType = MyType()
}
}
// usage:
val x: MyType = MyType.of()
public class MyType {
private MyType() {}
public static final class Companion {
public MyType of() { return new MyType(); }
}
public static final Companion Companion = new Companion();
}
// usage:
MyType x = MyType.Companion.of();
X’s companion object’s member M tagged with @JvmStatic is mapped to static member M on class Ximport kotlin.jvm.JvmStatic
class MyType private constructor() {
companion object {
@JvmStatic
fun of(): MyType = MyType()
}
}
// usage:
val x: MyType = MyType.of()
public class MyType {
private MyType() {}
public static MyType of() { return new MyType(); }
}
// usage:
MyType x = MyType.of();
fun f(vararg xs: Int) {
val ys: IntArray = xs
}
void f(int... xs) {
int[] ys = xs;
}
// file MyFile.kt
class MyType { }
fun MyType.myMethod() {}
// usage:
val x = MyType()
x.myMethod() // postfix
public final class MyFileKt {
public static void myMethod(MyType $this$myMethod) {}
}
// usage:
MyType x = new MyType();
MyFileKt.myMethod(x); // prefix
import kotlin.sequences.*
val x = (0 ..< 10).asSequence() // 0, 1, 2, ..., 9
.filter { it % 2 == 0 } // 0, 2, 4, ..., 8
.map { it + 1 } // 1, 3, 5, ..., 9
.sum() // 25
import static kotlin.sequences.SequencesKt.*;
int x = sumOfInt(
map(
filter(
asSequence(
new IntRange(0, 9).iterator()
),
it -> it % 2 == 0
),
it -> it + 1
)
);
fun f(a: Int = 1, b: Int = 2, c: Int = 3) { /* ... */ }
// usage:
f(b = 5)
void f(int a, int b, int c) { /* ... */ }
// usage:
f(1, 5, 3);
@JvmOverloads to generate overloaded methods for Java
import kotlin.jvm.JvmOverloads
@JvmOverloads
fun f(a: Int = 1, b: Int = 2, c: Int = 3) { /* ... */ }
void f(int a, int b, int c) { /* ... */ }
void f(int a, int b) { f(a, b, 3); }
void f(int a) { f(a, 2, 3); }
void f() { f(1, 2, 3); }
// overloads that are not generated (some would even clash, having the same JVM signature):
// void f(int a, int c) { f(a, 2, c); }
// void f(int b, int c) { f(1, b, c); }
// void f(int c) { f(1, 2, c); }
// void f(int b) { f(1, b, 3); }
Kotlin has no checked exceptions: use @Throws to let Java callers catch them
@Throws(IOException::class)
fun readFile(path: String): String { /* ... */ } // Java: String readFile(String path) throws IOException
Kotlin’s nullability is exposed to Java via annotations (@NotNull, @Nullable)
String!), whose nullability is unknowninternal members are public in the bytecode, but their names are mangled
internal fun f() in module csv-core becomes f$csv_core(), which is discouraging to call from JavaReference: calling Kotlin from Java
Compile our CSV lib and import it as a dependency in a novel Java library
Alternatively, add Java sources to the jvmTest source set, and use the library
Listen to the teacher presenting key points
Disclaimer: the generated JS code is not really meant to be read by humans
main”@JsExport annotation
kotlin.js.ExperimentalJsExport opt-in@JsExport.Ignorepackage my.pkg
import kotlin.js.JsExport
@JsExport
class MyType {
@JsExport.Ignore // Sequence is not exportable
val numbers: Sequence<Int> = sequenceOf(1, 2, 3)
}
@JsExport
fun myFunction() { /* ... */ }
// ES modules: packages are flattened
import { MyType, myFunction } from "project-name";
// CommonJS / UMD: packages are preserved
const pkg = require("project-name").my.pkg;
const { MyType, myFunction } = pkg;
@JsName annotation can be used to control the name of a member
package my.pkg
@JsName("sumNumbers")
fun sum(vararg numbers: Int): Int = numbers.sum()
@JsName("sumIterableOfNumbers")
fun sum(numbers: Iterable<Int>): Int = numbers.sum()
@JsName("sumSequenceOfNumbers")
fun sum(numbers: Sequence<Int>): Int = numbers.sum()
import {
sumNumbers,
sumIterableOfNumbers,
sumSequenceOfNumbers
} from "project-name";
class MyClass(private val argument: String) {
@JsName("method")
fun method(): String = argument + "!"
}
function MyClass(argument) {
this.randomName_1 = argument
}
MyClass.prototype.method = function () {
return this.randomName_1 + "!"
}
Kotlin numeric types, except for kotlin.Long, are mapped to JavaScript Number
kotlin.Char is mapped to a JS Number representing the character code.
Kotlin preserves overflow semantics for kotlin.Int, kotlin.Byte, kotlin.Short, kotlin.Char and kotlin.Long
kotlin.Long is emulated by a Kotlin class by default
BigInt (experimental), with the es2020 target or the -Xes-long-as-bigint compiler optionkotlin.String is mapped to JS String
kotlin.Any is mapped to JS Object (new Object(), {}, and so on)
kotlin.Array is mapped to JS Array
Kotlin collections are mapped to KtList, KtSet, KtMap (and their mutable variants)
asJsReadonlyArrayView()kotlin.Throwable is mapped to JS Error
practical consequence: no way to distinguish numbers by type
val x: Int = 23
val y: Any = x
println(y as Float) // fails on JVM, works on JS
Kotlin’s dynamic overrides the type system, and it is translated “1-to-1”
val string1: String = "some string"
string1.missingMethod() // compilation error
val string2: dynamic = "some string"
string2.missingMethod() // compilation ok, runtime error
kotlin-kotlin-stdlib.mjs), and dead code is eliminatedCompanion objects are treated similarly to Kotlin/JVM
Extension methods are treated similarly to Kotlin/JVM
Variadic functions are compiled to JS functions accepting an array
fun f(vararg xs: String) { /* ... */ }
// usage:
f("a")
f("a", "b")
f("a", "b", "c")
// usage:
f(["a"])
f(["a", "b"])
f(["a", "b", "c"])
Conceptual workflow (cf. build matrices in the CI lecture):
O (e.g. Win, Mac, Linux):
T:
V of the platform T (e.g. LTS releases + latest)
<T>MainClasses)<T>TestClasses)<T>Test)T $\cup$ kotlin-multiplatform:
R of platform TEven more complicated, because release on Maven Central is complex
Setting up a CI/CD pipeline of this kind requires a lot of work
Recommendations:
template-kt-mpp-projectkt-mpp or vanniktech/gradle-maven-publish-plugin2p-ktTakeaway: each platform has some preferred main repository where users expect to find packages
JVM $\rightarrow$ Maven Central Repository (MCR)
Kotlin / Multiplatform $\rightarrow$ MCR
JS $\rightarrow$ npm registry
Android $\rightarrow$ libraries: MCR or Google’s Maven repository; apps: Google Play
Mac / iOS $\rightarrow$ libraries: Swift Package Manager or CocoaPods; apps: App Store
Windows $\rightarrow$ Microsoft Store
Linux $\rightarrow$ depends on the distro
Python $\rightarrow$ PyPI
.NET $\rightarrow$ NuGet
At the end of the day, the Python interpreter is C software
Most commonly, efficient Python code is written in C and then wrapped into Python
Put simply, the JVM too is C++ software
Can Python libraries be written in Java (or Kotlin/JVM) and then wrapped into Python?
Apparently yes, via several ad-hoc bridging technologies:
Why exactly JPype?
Ensure you have a JVM installed on your system
JAVA_HOME environment variable set to the JVM installation directoryEnsure your compiled Java code is available as a .jar file
/path/to/my.jarInstall the JPype package via pip install JPype1
You first need JPype to start a JVM instance in your Python process
JAVA_HOME environment variableimport jpype
# start the JVM
jpype.startJVM(classpath=["/path/to/my.jar"])
Once the JVM is started, one can import Java classes and call their methods as if they were Python objects
import jpype.imports # this is necessary to import Java classes
from java.lang import System # import the java.lang.System class
System.out.println("Hello World!")
jpype.imports works for well-known top-level domains (e.g. java, javax, com, org)
io.github.*) use jpype.JClass("...") / jpype.JPackage("..."), or jpype.imports.registerDomain("io")convertStrings=False), Java methods return Java strings, to be converted via str(...) when neededOverview on the official documentation
Java classes are presented as similarly to Python classes as possible
the only major difference is that Java classes and objects are closed and cannot be modified
| Description | Java | Python |
|---|---|---|
| Import a class | import org.pkg.MyClass |
from org.pkg import MyClass |
| Import a class without a well-known TLD | import zippy.NonStandard |
NonStandard = JClass('zippy.NonStandard') |
| Construct an object | MyClass o = new MyClass(1); |
o = MyClass(1) |
| Get / set a static field | MyClass.staticField = 2; |
MyClass.staticField = 2 |
| Call a static / member method | MyClass.callStatic(1); o.callMember(1); |
MyClass.callStatic(1); o.callMember(1) |
| Member with Python naming conflict | o.pass() |
o.pass_() |
| Check inheritance | o instanceof MyClass |
isinstance(o, MyClass) |
| Cast to a specific type | (BaseClass) o |
BaseClass @ o |
Overview on the official documentation
Java exceptions extend from Python exceptions
Java exceptions can be dealt with in the same way as Python native exceptions
try-except blocksJException serves as the base class for all Java exceptions
| Description | Java | Python |
|---|---|---|
| Catch an exception | try { o.throwsException(); } catch (java.lang.Exception ex) { ... } |
try: o.throwsException()except java.lang.Exception as ex: ... |
| Throw an exception to Java | throw new java.lang.Exception("Problem"); |
raise java.lang.Exception("Problem") |
| Check if an object is a Java exception | isinstance(obj, JException) |
|
| Closeable items | try (InputStream is = Files.newInputStream(file)) { ... } |
with Files.newInputStream(file) as is: ... |
Overview on the official documentation
most Python primitives directly map into Java primitives
however, Python does not have the same primitive types…
… hence, explicit casts may be needed in some cases
each primitive Java type is exposed in JPype (jpype.JBoolean, .JByte, .JChar, .JShort, .JInt, .JLong, .JFloat, .JDouble).
| Description | Java | Python |
|---|---|---|
| Cast to hit an overload | o.call((int) v); |
o.call(JInt(v)) |
| Create a primitive array | int[] array = new int[5]; |
array = JInt[5] |
| Create a rectangular primitive array | int[][] array = new int[5][10]; |
array = JInt[5, 10] |
| Create an array of arrays | int[][] array = new int[5][]; |
array = JInt[5, :] |
| Create an initialised primitive array | int[] array = new int[]{1, 2, 3}; |
array = JInt[:]([1, 2, 3]) |
| Create an initialised boxed array | Integer[] array = new Integer[]{1, 2, 3}; |
array = java.lang.Integer[:]([1, 2, 3]) |
| Put a specific primitive type on a list | List<Integer> l = new ArrayList<>();l.add(1); |
l = ArrayList()l.add(JInt(1)) |
| Box a primitive | Integer boxed = 1; |
boxed = JObject(JInt(1)) |
Overview on the official documentation
Java strings are similar to Python strings
they are both immutable and produce a new string when altered
most operations can use Java strings in place of Python strings
when comparing or using strings as dictionary keys, all JString objects should be converted to Python
| Description | Java | Python |
|---|---|---|
| Create a Java string | String s = new String("foo"); |
s = JString("foo") |
| Create a Java string from bytes | byte[] b;String s = new String(b, "UTF-8"); |
b = b'foo's = JString(b, "UTF-8") |
| Convert a Java string | str(javaStr) |
|
| Compare Python and Java strings | str(javaStr) == pyString |
|
| Compare Java strings | javaStr.equals("foo") |
javaStr == "foo" |
| Check if an object is a Java string | isinstance(obj, JString) |
Overview on the official documentation
Java arrays are mapped to Python lists
more precisely, they operate like Python lists, but they are fixed in size
reading a slice from a Java array returns a view of the array, not a copy
passing a slice of a Python list to Java will create a copy of the sub-list
| Description | Java | Python |
|---|---|---|
| Create a single-dimension array | MyClass[] array = new MyClass[5]; |
array = MyClass[5] |
| Create a multi-dimension array | MyClass[][] array2 = new MyClass[5][]; |
array2 = MyClass[5, :] |
| Access an element | array[0] = new MyClass(); |
array[0] = MyClass() |
| Size of an array | array.length |
len(array) |
| Get last element | MyClass a = array[array.length - 1]; |
a = array[-1] |
| Slice an array | a = array[2:5] |
|
| Clone an array | MyClass[] a = array.clone(); |
a = array.clone() |
| Convert to Python list | pylist = list(array) |
|
| Iterate elements | for (MyClass element : array) { ... } |
for element in array: ... |
| Check if an object is a Java array | isinstance(obj, JArray) |
Overview on the official documentation
Java collections are overloaded with Python syntax where possible
Java’s Iterables are mapped to Python iterables by overriding the __iter__ method
Java’s Collections are mapped to Python containers by overriding __len__
Java’s Maps support Python’s dictionary syntax by overriding __getitem__ and __setitem__
Java’s Lists support Python’s list syntax by overriding __getitem__ and __setitem__
| Description | Java | Python |
|---|---|---|
| Import list type | import java.util.ArrayList; |
from java.util import ArrayList |
| Construct a list | List<Integer> l = new ArrayList<>(); |
l = ArrayList() |
| Get length of list | int sz = l.size(); |
sz = len(l) |
| Get list item | Integer i = l.get(0); |
i = l[0] |
| Set list item | l.set(0, 1); |
l[0] = JInt(1) |
| Iterate list elements | for (Integer e : l) { ... } |
for e in l: ... |
| Import map type | import java.util.HashMap; |
from java.util import HashMap |
| Construct a map | Map<String, Integer> m = new HashMap<>(); |
m = HashMap() |
| Get length of map | int sz = m.size(); |
sz = len(m) |
| Get map item | Integer i = m.get("foo"); |
i = m["foo"] |
| Set map item | m.put("foo", 1); |
m["foo"] = JInt(1) |
| Iterate map entries | for (Map.Entry<String, Integer> e : m.entrySet()) { ... } |
for e in m.entrySet(): ... |
Overview on the official documentation
Java interfaces can be implemented in Python, via JPype’s @JImplements and @JOverride decorators
No Java class (not even abstract ones) can be extended in Python
Python lambda expressions can be cast to Java’s functional interfaces
| Description | Java | Python |
|---|---|---|
| Implement an interface | public class PyImpl implements MyInterface { |
@JImplements(MyInterface) |
| Extend a class | public class PyImpl extends MyClass { ... } |
not supported |
| Lambdas | DoubleUnaryOperator u = (p -> p * 2); |
u = DoubleUnaryOperator @ (lambda x: x * 2) |
| Add methods to an existing Java class | @JImplementationFor("java.util.Map") |
Overview on the official documentation
| Python \ Java | byte |
short |
int |
long |
float |
double |
boolean |
char |
String |
Array | Object | j.l.Object |
j.l.Class |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
int |
I1 | I1 | X | I | I3 | I3 | X8 | I11 | |||||
float |
I1 | X | I11 | ||||||||||
str |
I2 | X | I | ||||||||||
JByte |
X | I9 | |||||||||||
JShort |
X | I9 | |||||||||||
JInt |
X | I9 | |||||||||||
JLong |
X | I9 | |||||||||||
JFloat |
X | I9 | |||||||||||
JDouble |
X | I9 | |||||||||||
JBoolean |
X | I9 | |||||||||||
JChar |
X | I9 | |||||||||||
JString |
X | I | |||||||||||
JArray |
I/X4 | I | |||||||||||
JObject |
I/X6 | I/X7 | I/X7 | ||||||||||
JClass |
I | X | |||||||||||
| boxed10 | I | I | I | I | I | I | I | I |
Legend (j.l = java.lang):
Notes:
True and Falsejava.lang.NumberConsider the following example of Python code with JPype:
import jpype.imports
from java.lang import System
System.out.println(1)
System.out.println(2.0)
System.out.println('A')
Which overload of System.out.println is called among the many admissible ones?
2.0 is convertible to Java’s float and double
double is the exact match'A' is convertible to Java’s String and char
String is the exact match1 is implicitly convertible to many types (e.g. int, long, char, boolean, Object)
TypeError: Ambiguous overloads found for java.io.PrintStream.println(int)System.out.println(jpype.JInt(1))Consider the following example of Python code with JPype:
import jpype
csv = jpype.JClass("io.github.gciatto.csv.Csv")
csv.headerOf(["field", "another field"])
This would raise the following error:
TypeError: Ambiguous overloads found for io.github.gciatto.csv.Csv.headerOf(list) between:
public static final io.github.gciatto.csv.Header io.github.gciatto.csv.Csv.headerOf(java.lang.Iterable)
public static final io.github.gciatto.csv.Header io.github.gciatto.csv.Csv.headerOf(java.lang.String[])
list is convertible to both Java’s Iterable and String[]
To solve this issue, one can explicitly cast the Python list to the desired Java type:
import jpype
import jpype.imports
from java.lang import Iterable as JIterable
csv = jpype.JClass("io.github.gciatto.csv.Csv")
csv.headerOf(JIterable@["field", "another field"])
# returns Header("field", "another field")
One may customise the behaviour of Java types in Python by providing custom implementations for them
@JImplementationFor decoratorIn that case the special method __jclass_init__ is called on the custom implementation, just once, to configure the class
In type hierarchies, implementations provided for superclasses are inherited by subclasses
Consider for instance the following customisations, allowing Java collections to be used with Python syntax
from collections.abc import Sequence
@jpype.JImplementationFor("java.lang.Iterable")
class _JIterable:
def __iter__(self):
return self.iterator() # supports "for x in coll" syntax
@jpype.JImplementationFor("java.util.Collection")
class _JCollection:
def __len__(self):
return self.size() # supports "len(coll)" syntax
def __contains__(self, i):
return self.contains(i) # supports "i in coll" syntax
# __iter__ is inherited from _JIterable
# because in Java: Collection extends Iterable
@jpype.JImplementationFor("java.util.List")
class _JList:
def __jclass_init__(self):
# makes isinstance(x, Sequence) true for Java lists
Sequence.register(self)
def __getitem__(self, ndx):
return self.get(ndx) # supports "list[i]" syntax
def append(self, obj):
return self.add(obj) # supports "list.append(obj)" syntax
# __len__, __contains__, __iter__ are inherited
# from _JCollection
simplified from JPype’s codebase
The code wrapped via JPype is not Pythonic by default
It is important to make the wrapped code as Pythonic as possible
io.github.gciatto.csv.Csv $\rightarrow$ jcsv.Csvsnake_case instead of camelCase__len__ for java.util.Collection__getitem__ for java.util.ListAll such refinements can be done in JPype via customisations of the Java types
For all public types in the wrapped Java library:
jcsv package (pt. 1)The jcsv package (module csv-python) is a Pythonic wrapper for our JVM-based io.github.gciatto.csv library
Java’s type definitions are brought to Python in jcsv/init.py:
import typing
import jpype
from jcsv import (
jvm, # starts the JVM, with csv.jar on the classpath
python,
)
_csv = jpype.JPackage("io.github.gciatto.csv")
_java = jpype.JPackage("java.lang")
Table = _csv.Table
Row = _csv.Row
Record = _csv.Record
Header = _csv.Header
Formatter = _csv.Formatter
Parser = _csv.Parser
Configuration = _csv.Configuration
Csv = _csv.Csv
CsvJvm = _csv.CsvJvmjcsv.jvm starts the JVM (see later), hence it must come before any access to Java typesCsv and CsvJvm are the Java classes generated from Csv.kt and Csv.jvm.kt, thanks to @file:JvmNamefrom jcsv import Table, Record, Header on the user sidejcsv package (pt. 2)Parsing and formatting operations are mapped straightforwardly to Python functions (with optional parameters replacing Kotlin’s default arguments):
def parse_csv_string(
string,
separator=Csv.DEFAULT_SEPARATOR,
delimiter=Csv.DEFAULT_DELIMITER,
comment=Csv.DEFAULT_COMMENT,
):
return Csv.parseAsCSV(string, separator, delimiter, comment)
def parse_csv_file(
path,
separator=Csv.DEFAULT_SEPARATOR,
delimiter=Csv.DEFAULT_DELIMITER,
comment=Csv.DEFAULT_COMMENT,
):
return CsvJvm.parseCsvFile(str(path), separator, delimiter, comment)
def format_as_csv(
rows,
separator=Csv.DEFAULT_SEPARATOR,
delimiter=Csv.DEFAULT_DELIMITER,
comment=Csv.DEFAULT_COMMENT,
):
return Csv.formatAsCSV(jvm.Iterable @ rows, separator, delimiter, comment)jcsv package (pt. 3)Ad-hoc factory method for building Header instances:
def header(*args):
if len(args) == 1 and isinstance(args[0], int):
return Csv.anonymousHeader(args[0])
return python.iterable_or_varargs(args, lambda xs: Csv.headerOf(jvm.Iterable @ map(str, xs)))making it possible to write the following code on the user side:
import jcsv
header1 = jcsv.header("column1", "column2", "column3")
header2 = jcsv.header(3) # anonymous header with 3 columns
columns = (f"column{i}" for i in range(1, 4)) # generator
header3 = jcsv.header(columns) # same as header1
Function iterable_or_varargs (in jcsv/python.py) simulates multiple overloads:
def iterable_or_varargs(
args: tuple,
dispatch: Callable[[Iterable[T]], R] = lambda x: x,
atomic: tuple = (str, bytes),
) -> R:
"""
Simulates the overloads f(*items) and f(iterable_of_items):
a single iterable argument is unpacked, unless it is an instance of one of the `atomic` types.
"""
if len(args) == 1:
item = args[0]
if isinstance(item, Iterable) and not isinstance(item, atomic):
return dispatch(item)
return dispatch([item])
return dispatch(args)header("col") must not split "col" into charactersjcsv package (pt. 4)How can jvm.Iterable @ map(str, xs) work, if Python’s map is not convertible to Java’s Iterable?
A custom conversion is registered in jcsv/jvm/__jvmutils.py, via the @JConversion decorator:
def jiterable(iterable: PyIterable) -> Iterable:
assert isinstance(iterable, PyIterable)
return _IterableAdapter(iterable)
@jpype.JConversion("java.lang.Iterable", instanceof=PyIterable, excludes=str)
def _java_iterable_convert(jcls, obj):
return jiterable(obj)_IterableAdapter is a Python class implementing Java’s Iterable interface:@jpype.JImplements("java.lang.Iterable", deferred=True)
class _IterableAdapter:
def __init__(self, iterable):
assert isinstance(iterable, PyIterable)
self._iterable = iterable
@jpype.JOverride
def iterator(self):
return _IteratorAdapter(iter(self._iterable))_IteratorAdapter similarly implements java.util.Iterator on top of Python iteratorsjcsv package (pt. 5)Ad-hoc factory methods for building Record and Table instances:
def record(header, *args):
return python.iterable_or_varargs(
args, lambda xs: Csv.recordOf(header, jvm.Iterable @ map(str, xs))
)
def __ensure_header(h):
return h if isinstance(h, Header) else header(h)
def __ensure_record(r, h):
return r if isinstance(r, Record) else record(h, r)
def table(header, *args):
header = __ensure_header(header)
return python.iterable_or_varargs(
args,
lambda rows: Csv.tableOf(header, jvm.Iterable @ [__ensure_record(r, header) for r in rows]),
atomic=(str, bytes, Row), # a single Row is a record, not an iterable of records
)table accepts headers and records either as Java objects or as Python iterables, e.g.:
jcsv.table(["a", "b"], [1, 2], [3, 4])
jcsv package (pt. 6)The Row class is customised to make it more Pythonic:
@jpype.JImplementationFor("io.github.gciatto.csv.Row")
class _Row:
def __len__(self) -> int:
return self.getSize()
def __getitem__(self, item: int | str) -> str:
if isinstance(item, int) and item < 0:
item = len(self) + item
try:
return self.get(item)
except _java.IndexOutOfBoundsException as e:
raise IndexError(f"index {item} out of range") from e
@property
def size(self) -> int:
return len(self)len(row) instead of row.getSize()row[i] instead of row.get(i)row[-i] instead of row.get(row.getSize() - i)IndexError be raised instead of IndexOutOfBoundsExceptionrow.size instead of row.getSize()jcsv package (pt. 7)Header inherits all customisations of Row (since Header extends Row), plus the following ones:
@jpype.JImplementationFor("io.github.gciatto.csv.Header")
class _Header:
@property
def columns(self) -> list[str]:
return [str(c) for c in self.getColumns()]
def __contains__(self, item: str) -> bool:
return self.contains(item)
def index_of(self, column: str) -> int:
return self.indexOf(column)header.columns instead of header.getColumns()column in header instead of header.contains(column)header.index_of(column) instead of header.indexOf(column)jcsv package (pt. 8)Record inherits all customisations of Row (since Record extends Row), plus the following ones:
@jpype.JImplementationFor("io.github.gciatto.csv.Record")
class _Record:
@property
def header(self) -> Header:
return self.getHeader()
@property
def values(self) -> list[str]:
return [str(v) for v in self.getValues()]
def __contains__(self, item: str) -> bool:
return self.contains(item)record.header instead of record.getHeader()record.values instead of record.getValues()value in record instead of record.contains(value)jcsv package (pt. 9)The Table class is customised too, to make it more Pythonic:
@jpype.JImplementationFor("io.github.gciatto.csv.Table")
class _Table:
@property
def header(self) -> Header:
return self.getHeader()
def __len__(self) -> int:
return self.getSize()
def __getitem__(self, item: int) -> Row:
if isinstance(item, int) and item < 0:
item = len(self) + item
try:
return self.get(item)
except _java.IndexOutOfBoundsException as e:
raise IndexError(f"index {item} out of range") from e
@property
def records(self) -> list[Record]:
return self.getRecords()
@property
def size(self) -> int:
return len(self)table.header instead of table.getHeader()len(table) instead of table.getSize()table[i] instead of table.get(i)table[-i] instead of table.get(table.getSize() - i)table.records instead of table.getRecords().jars in JPype projects (pt. 1)csv-python/
├── build.gradle.kts # Gradle build: generation of csv.jar + Python tasks
├── pyproject.toml # Python project description (generated from Gradle, tracked)
├── uv.lock # pinned versions of Python dependencies
├── .python-version # Python version used for development
├── jcsv/
│ ├── __init__.py
│ ├── __main__.py # CLI, installed as the `jcsv` command
│ ├── jvm/
│ │ ├── __init__.py # this is where the JVM is started via JPype
│ │ ├── __jvmutils.py # Java <-> Python conversions
│ │ └── csv.jar # the fat JAR of the JVM-based library (generated, not tracked)
│ └── python.py
└── tests/
├── __init__.py
├── test_parsing.py
└── test_python_api.py
We need to ensure that the JVM-based library is available on the system where jcsv is installed
The build.gradle.kts file automates the generation of the csv.jar file
jcsv/jvm directory, i.e. inside the Python packageuv build, and distributed with the Python libraryThe jcsv/jvm/__init__.py file starts the JVM, with csv.jar on the class path
.jars in JPype projects (pt. 2)The build.gradle.kts of csv-python relies on the gradle-python-uv plugin, mapping Gradle’s lifecycle tasks (test, check, assemble, …) onto uv commands:
plugins {
id("io.github.gciatto.python-uv")
}
python {
pyproject {
name = "jcsv"
description = "Pythonic wrapper of a Kotlin/JVM CSV library, via JPype"
dependency("JPype1>=1.7.1")
dependency("jdk4py>=25.0.2.1")
// no mypy: JPype customisers call Java methods that only exist at runtime
poeSequence("static-checks", "compile", "format-check", "ruff-check")
}
}
// Copies the fat JAR of :csv-core into the Python package, as jcsv/jvm/csv.jar
val createCoreJar by tasks.registering(Copy::class) {
group = "Python"
val shadowJar by project(":csv-core").tasks.getting(Jar::class)
dependsOn(shadowJar)
from(shadowJar.archiveFile) {
rename(".*?\\.jar", "csv.jar")
}
into(projectDir.resolve("jcsv/jvm"))
}
// tests, static checks, and packaging (uv build) all need csv.jar
tasks.withType<PoeTask>().configureEach { dependsOn(createCoreJar) }
tasks.named("uvBuild") { dependsOn(createCoreJar) }pyproject.toml is generated from the python { pyproject { ... } } block (./gradlew generatePyproject)createCoreJar copies the fat JAR of csv-core (created by the Shadow plugin) into the Python packageWe need to ensure that some JVM is available on the system where jcsv is installed
Notice that the JVM is available as a Python dependency too: jdk4py
So, the JVM can be automatically downloaded and installed as a dependency of jcsv (see dependency("jdk4py>=25.0.2.1") in the build file)
Then, one may simply configure JPype to use that JVM, in jcsv/jvm/init.py:
import sys
from pathlib import Path
import jpype
from jdk4py import JAVA_HOME
# the directory where csv.jar is placed
CLASSPATH = Path(__file__).parent
# the list of all .jar files in CLASSPATH
JARS = [str(j.resolve()) for j in CLASSPATH.glob("*.jar")]
# the JVM library shipped by jdk4py, for the current OS
JVM_LIB = {"win32": "jvm.dll", "darwin": "libjli.dylib"}.get(sys.platform, "libjvm.so")
JVM_PATH = str(next(JAVA_HOME.rglob(JVM_LIB)))
jpype.startJVM(jvmpath=JVM_PATH, classpath=JARS)./gradlew :csv-python:assemble, then pip install csv-python/build/python/dist/*.whl in a clean virtual environmentUnit tests are essential to ensure the correctness of the Pythonic API
It is important to test all the customisations and factory methods
Consider for instance tests in tests/test_parsing.py and tests/test_python_api.py, e.g.:
class TestFactories(unittest.TestCase):
def test_single_string_column(self):
self.assertEqual(jcsv.header("col").columns, ["col"])
def test_iterable_of_columns(self):
self.assertEqual(jcsv.header(f"c{i}" for i in range(3)).columns, ["c0", "c1", "c2"])
def test_single_record_table(self):
h = jcsv.header("a", "b")
t = jcsv.table(h, jcsv.record(h, "1", "2"))
self.assertEqual(len(t), 2)
self.assertEqual(t[1].values, ["1", "2"])./gradlew :csv-python:test (i.e. uv run poe test, i.e. pytest)
createCoreJar, so the JAR is always up to datejcsv package (recap)| Write once, build anywhere (e.g. Kotlin MP) | Write first, wrap elsewhere (e.g. JPype) | |
|---|---|---|
| Code base | one, in a “super-language”, plus platform-specific parts | one main implementation, plus one wrapper per platform |
Effort vs. N platforms |
common code written once, platform-specific code N times |
API / wrapper code (and its tests) written N times |
| Runtime | native runtime of each platform | the main platform’s runtime is shipped along (e.g. JVM inside a Python wheel) |
| Idiomatic API | partially (e.g. @JvmName, @JsExport, @JsName) |
fully customisable in each wrapper |
| Limitations | small common std-lib, platform-specific build/release tooling | bridging overhead, type-conversion ambiguities, larger packages |
Both approaches require clear separation between API and implementation, and tests on every platform
Compiled on: 2026-11-06 — printable version