Nach den ersten Beispielskripten mit Rust migriere ich meinen Python-basierten Integritätsmonitor. Einen signifikanten Geschwindigkeitszuwachs erwarte ich nicht, weil das Skript im Wesentlichen aus I/O-Operationen und der Berechnung von Hash-Werten besteht (und Python sich diesbezüglich auf eine C-Bibliothek stützt). Allerdings beansprucht die Rust-Version rund dreimal so viel Zeit (4 vs. 12 Sekunden), was nicht gut zum Produktversprechen der Sprache passt. Es zeigt sich, dass der Wrapper sha256
mit der Methode try_digest()
sehr komfortabel, aber auch enorm langsam ist:
use sha256::try_digest;
use std::path::Path;
fn get_hash(filepath: &str) -> String {
let input = Path::new(filepath);
let hasher = try_digest(input).unwrap();
hasher
}
Meine Anfrage im Rust-Forum führt binnen Minuten zu Analysen (fixed buffer size
als Wettbewerbsvorteil für Python) und Lösungsvorschlägen (Verwendung von sha2
in Verbindung mit buffer streaming). Den Durchbruch bringen der Verweis auf die Assembly-Implementierung von Hash-Funktionen in sha2
(sha2 = { version = "0.10.6", features = ["asm", "asm-aarch64"] }
bzw. in ring
:
use ring::digest;
use sha2::{Digest, Sha256};
use hex;
fn get_hash_sha2(filepath: &str) -> String {
let bytes = std::fs::read(filepath).unwrap();
let mut h = Sha256::new();
h.update(&bytes[..]);
hex::encode(h.finalize())
}
fn get_hash_ring(filepath: &str) -> String {
let bytes = std::fs::read(filepath).unwrap();
let hasher = digest::digest(&digest::SHA256, &bytes);
hex::encode(hasher)
}
Ich entscheide mich für die (minimal schnellere) ring
-Variante:
use ring::digest;
use hex;
use walkdir::{DirEntry, WalkDir};
use std::fs;
use std::collections::HashMap;
use serde_json;
use std::io;
const HASHPATH: &str = "/Users/snafu/scripts/hashes_rust.json";
const DOCPATH: &str = "/Users/snafu/Documents";
fn get_hash(filepath: &str) -> String {
let bytes = std::fs::read(filepath).unwrap();
let hasher = digest::digest(&digest::SHA256, &bytes);
hex::encode(hasher)
}
fn is_hidden(entry: &DirEntry) -> bool {
entry.file_name()
.to_str()
.map(|s| s.starts_with("."))
.unwrap_or(false)
}
fn create_baseline() {
let file_content = fs::read_to_string(HASHPATH).unwrap();
let mut hash_storage: HashMap<String, String> = serde_json::from_str(&file_content).unwrap();
for entry in WalkDir::new(DOCPATH).follow_links(true).into_iter().filter_entry(|e| !is_hidden(e)) {
let file = entry.unwrap();
let metadata = match fs::metadata(&file.path()) {
Ok(value) => value,
Err(_) => continue,
};
if metadata.is_file() {
let filepath = file.path().display().to_string();
let hash = get_hash(&filepath);
if hash_storage.contains_key(&filepath) {
if hash_storage[&filepath] == hash {
continue;
} else {
println!("Hash for file '{filepath}' does not match baseline. Update hash and proceed (y) or cancel (n)? ");
let mut overwrite = String::new();
io::stdin().read_line(&mut overwrite).expect("Failed to read line");
match overwrite.trim() {
"y" => (),
_ => continue,
};
};
};
println!("Updating hash/creating new hash for file '{filepath}'...");
*hash_storage.entry(filepath).or_insert(hash) = hash.clone();
};
};
let mut to_remove = Vec::new();
for absolute_path in hash_storage.keys() {
match fs::metadata(&absolute_path) {
Ok(_) => continue,
Err(_) => {
println!("Removing hash for deleted file {absolute_path}...");
to_remove.push(absolute_path.to_owned());
},
};
}
for key in to_remove.iter() {
hash_storage.remove(key);
}
let serialized = serde_json::to_string(&hash_storage).unwrap();
fs::write(HASHPATH, serialized).expect("Unable to write file");
}
fn main() {
create_baseline();
}
Das Skript ist mit der Python-Version nahezu identisch, abgesehen von der Entfernung nicht mehr benötigter Hashes aus hash_storage
. Weil die Methode keys()
eine mutable reference auf hash_storage
erzeugt, kann das Objekt nicht innerhalb der for
-Schleife modifiziert werden. Stattdessen wird ein Hilfsobjekt vom Typ Vec<String>
mit den zu entfernenden Einträgen gefüllt und in einer zweiten for
-Schleife abgearbeitet.
Der Aufwand hat sich jedenfalls gelohnt:
snafu@local hasher % /usr/bin/time -h /usr/local/bin/hasher.py
3.95s real 1.33s user 0.78s sys
snafu@local hasher % /usr/bin/time -h target/release/hasher
2.13s real 1.23s user 0.57s sys
Darüber hinaus verleiht mir die freundliche Rust-Gemeinschaft den New User of the Month award for February 2023
:
This badge is granted to congratulate two new users each month for their excellent overall contributions, as measured by how often their posts were liked, and by whom.
Ich bin sehr gerührt.
Weil ich dem Sirenengesang Performance – Reliability – Productivity
gegenüber sehr aufgeschlossen bin, die kurze Haskell-Affäre vor vielen Jahren wenig erfüllend war und die Beschränkung auf eine einzige (wenn auch populäre) Programmiersprache die (durchaus berechtigten) Zweifel an meiner technischen Satisfaktionsfähigkeit kräftig schürt, befasse ich mich seit einigen Tagen mit Rust. Die Referenz-Einführung (the book
) bietet nicht nur ein passendes Lerntempo, sondern auch die richtige Mischung von Theorie und Praxis, und so kann ich am Ende des zweiten Kapitels drei Beispielaufgaben lösen.
Temperaturkonversion
Weltweit bestehen nur noch sehr wenige Länder darauf, Temperaturen ausschließlich in Fahrenheit anzugeben, und keines davon ist das Vereinigte Königreich. Trotzdem bleibt die Umrechnung zwischen Fahrenheit zu Celsius eine beliebte Übung. Mein Skript erwartet die Eingabe eines Zahlenwertes und der Maßeinheit:
use std::io;
fn to_celsius(fahrenheit_value: f32) {
let celsius_value = (fahrenheit_value - 32.0) * 5.0 / 9.0;
println!("{fahrenheit_value} Grad Fahrenheit entspricht {celsius_value} Grad Celsius.");
}
fn to_fahrenheit(celsius_value: f32) {
let fahrenheit_value = (celsius_value * 9.0 / 5.0) + 32.0;
println!("{celsius_value} Grad Celsius entspricht {fahrenheit_value} Grad Fahrenheit.");
}
fn main() {
loop {
let mut input = String::new();
println!("Bitte Temperaturwert und Ausgangseinheit – [F]ahrenheit oder [C]elsius – eingeben!");
io::stdin().read_line(&mut input).expect("Lesefehler!");
let mut splitted_input = input.split_whitespace();
let temperature_value = match splitted_input.next() {
Some(value) => value,
None => continue,
};
let temperature_value: f32 = match temperature_value.parse() {
Ok(num) => num,
Err(_) => continue,
};
let unit = splitted_input.next();
match unit {
Some("F") => to_celsius(temperature_value),
Some("C") => to_fahrenheit(temperature_value),
Some(&_) => { println!("Ungültige Ausgangseinheit!"); }
None => { println!("Fehlende Ausgangseinheit!"); }
}
break;
}
}
Die Eingabe wird als Zeichenkette (vom Typ String
) gespeichert und in zwei Elemente aufgeteilt. Das erste Element wird mit Hilfe von zwei match
-Ausdrücken daraufhin geprüft, ob es nicht leer ist und in einen Fließkommawert umgewandelt werden kann. Falls einer der beiden Schritte nicht zum Erfolg führt, wird die Schleife erneut ausgeführt – und die Nutzerin wieder zur Eingabe von Temperatur und Maßeinheit aufgefordert. Fehlt dagegen die Maßeinheit, oder besteht sie nicht aus den Zeichenketten C bzw. F, gibt das Skript eine Fehlermeldung aus.
Fibonacci
Bei der Berechnung der n-ten Fibonacci-Zahl verzichte ich auf Nutzereingaben und lasse einfach die 150. Zahl (rund 16,1 Quintillionen, genau: 16.130.531.424.904.581.415.797.907.386.349) berechnen:
fn main() {
let mut left = 1;
let mut right = 1;
let mut result: u128 = 0;
let target = 150;
let mut counter = 1;
while target > counter {
result = left + right;
left = right;
right = result;
counter += 1;
}
println!("The {target}th Fibonacci number is {result}");
}
Mit diesem simplen Skript lässt sich das Leistungsversprechen von Rust (im Vergleich mit Python) testen, indem man den obigen Code 10 Millionen mal ausführen lässt. Python benötigt 165,45 Sekunden – und Rust 0,62 (bei sehr viel geringerem Einsatz von Speicherkapazität und Rechenzyklen).
Weihnachtslied
Die dritte Aufgabe besteht darin, den Text eines sehr repetitiven Weihnachtsliedes vollständig auszuschreiben.
fn main() {
let material = [
("first", "and a partridge in the pear tree."),
("second", "two turtle doves"),
("third", "three French hens"),
("fourth", "four calling birds"),
("fifth", "five gold rings"),
("sixth", "six geese a-laying"),
("seventh", "seven swans a-swimming"),
("eight", "eight maids a-milking"),
("ninth", "nine ladies dancing"),
("tenth", "ten lords a-leaping"),
("eleventh", "eleven pipers piping"),
("twelfth", "twelve drummers drumming")
];
let mut storage = String::new();
for (day, gift) in material {
println!("On the {day} day of christmas, my true love sent to me\n{gifted}", gifted=gift.strip_prefix("and ").unwrap_or(gift));
println!("{storage}");
storage = gift.to_owned() + "\n" + &storage;
}
}
Abgesehen von der Methode strip_prefix()
, mit der das initiale and
bei der ersten Verwendung der Zeile and a partridge in the pear tree
entfernt wird, ist nur die letzte Zeile etwas erklärungsbedürftig, denn:
When using strings, Rust beginners puzzle over String
vs. &str
, and Haskell beginners puzzle over String
vs. Text
vs. ByteString
.
Das kann ich sowohl für Haskell als auch für Rust bestätigen. Was geht in der Codezeile vor? Die Add
-Methode verwendet den Buffer des ersten Arguments vom Typ String
(owned), kopiert den Inhalt des zweiten Arguments vom Typ &str
(borrowed) in diesen Buffer und liefert den erweiterten String
zurück. Entsprechend muss die Variable gift
(Typ &str
) in einen String
umgewandelt werden, während storage
als &storage
(Typ &str
) den umgekehrten Weg geht. Um Ownership, References und Borrowing für einen Moment auszublenden, lässt sich alternativ insert_str()
zweimal aufrufen:
storage.insert_str(0, "\n");
storage.insert_str(0, gift);
Mit Mastodon 4.1.0 lassen sich auf meiner Instanz endlich auch Avatare und Header begrenzen:
mastodon@snafu:~/live$ bin/tootctl media usage
Attachments: 1.01 GB (1.33 MB local)
Custom emoji: 188 MB (12.7 KB local)
Preview cards: 24.9 MB
Avatars: 9.87 GB (96.5 KB local)
Headers: 19.8 GB (413 KB local)
Backups: 0 Bytes
Imports: 0 Bytes
Settings: 0 Bytes
mastodon@snafu:~/live$ bin/tootctl media remove --days=2 --prune-profiles
Visited 122464 accounts and removed profile media totaling 29.3 GB
mastodon@snafu:~/live$ bin/tootctl media remove --days=2 --remove-headers
Visited 15010 accounts and removed profile media totaling 179 KB
mastodon@snafu:~/live$ bin/tootctl media usage
Attachments: 1.01 GB (1.33 MB local)
Custom emoji: 188 MB (12.7 KB local)
Preview cards: 25 MB
Avatars: 125 MB (96.5 KB local)
Headers: 308 MB (413 KB local)
Backups: 0 Bytes
Imports: 0 Bytes
Settings: 0 Bytes
Obwohl sich die Interaktion mit der Miethai-Anwendung überwiegend in der Administrationsoberfläche abspielt, werden zwei (zugangsbeschränkte) Schnittstellen zur Außenwelt benötigt, eine Webansicht der Nebenkostenabrechnung und ein E-Mail-Versand. Beides ist mit minimalen Aufwand zu realisieren:
# ../landlord/settings.py
LOGIN_URL = '/admin/login/'
# urls.py
urlpatterns = [
path('balance/<balance_id>', views.display_balance, name='display_balance'),
path('email/balance/<balance_id>', views.email_balance, name='email_balance'),
]
# views.py
@login_required
def display_balance(request, balance_id):
balance = Balance.objects.get(pk=balance_id)
balance_entries = BalanceEntry.objects.filter(balance=balance)
return render(request, 'index.html', { 'balance': balance, 'balance_entries' : balance_entries })
@login_required
def email_balance(request, balance_id):
balance = Balance.objects.get(pk=balance_id)
balance_entries = BalanceEntry.objects.filter(balance=balance)
context = { 'balance': balance, 'balance_entries' : balance_entries }
subject = f'Nebenkostenabrechnung {balance.begin.year}'
message = loader.get_template('email.txt').render(context, request)
html_message = loader.get_template('email.html').render(context, request)
from_email = 'wohnung@eden.one'
recipient_list = [balance.tenancy_agreement.tenant.email]
send_mail(subject, message, from_email, recipient_list, html_message=html_message)
return display_balance(request, balance_id)
Das Template index.html
enthält eine tabellarische Darstellung der gewählten Abrechnung –
<html lang="de">
<head>
<meta charset="utf-8" />
{% block css %}<link rel="stylesheet" href="/static/css/landlord.css" />{% endblock %}
</head>
<body>
{% block email %}{% endblock %}
{% block heading %}<h1>Nebenkostenabrechnung {{balance.begin.year}}</h1>{% endblock %}
<ul><li><strong>Objekt</strong>: {{balance.tenancy_agreement.unit.building.address}} ({{balance.tenancy_agreement.unit.location}})</li>
<li><strong>Mietvertrag</strong>: {{balance.tenancy_agreement.public_id}}</li>
<li><strong>Mieter*in</strong>: {{balance.tenancy_agreement.tenant}}</li>
<li><strong>Abrechnungsdatum</strong>: {{balance.date}}</li>
<li><strong>Abrechnungszeitraum</strong>: {{balance.begin}} – {{balance.end}}</li>
</ul>
<table><tr><th>Dienst</th><th>Gesamtbetrag</th><th>Anteil (Leistung)</th><th>Anteil (Zeitraum)</th><th>Anteil (Rechnung)</th><th>Betrag</th></tr>
{% for entry in balance_entries %}
<tr><td>{{entry.service.name}}</td><td>{{entry.invoice_amount_display}}</td><td>{{entry.service_share_display}}</td><td>{% if entry.service.service_type == "CO" %}–{% else %}{{entry.days_share_display}}{% endif %}</td><td>{{entry.invoice_share_display}}</td><td>{{entry.amount_display}}</td></tr>
{% endfor %}
<tr><td><strong>Gesamtbetrag:</strong></td><td></td><td></td><td></td><td></td><td><strong>{{balance.total_display}}</strong></td></tr>
<tr><td>Vorauszahlungen:</td><td></td><td></td><td></td><td></td><td>{{balance.total_payments_display}}</td></tr>
<tr><td><strong>{{balance.payment_or_return}}:</strong></td><td></td><td><td></td></td><td></td><td><strong>{{balance.payment_due_display}}</strong></td></tr>
</table>
<p>{% if balance.payment_due < 0 %}Das <strong>Guthaben von {{balance.payment_due_display}}</strong> wird auf Ihr Konto {{balance.tenancy_agreement.account_iban}} (BIC: {{balance.tenancy_agreement.account_bic}}) überwiesen.{% else %}Bitte überweisen Sie die <strong>Nachzahlung i.H.v. {{balance.payment_due_display}}</strong> auf unser Konto IBAN DE12 3848 3838 2828 3848 (BIC: DEFGMWW).{% endif %}</p>
{% block email_link %}{% if balance.tenancy_agreement.tenant.email %}<p><a href="{% url 'email_balance' balance_id=balance.pk %}">Als E-Mail senden</a></p>{% endif %}{% endblock %}
</body></html>
– und das Template email.html
modifiziert diese Ansicht geringfügig:
{% extends 'index.html' %}
{% block css %}<style>
[...]
</style>{% endblock %}
{% block email %}<p>Sehr geehrte*r {{balance.tenancy_agreement.tenant}},<br />anbei erhalten Sie Ihre Nebenkostenabrechnung für das Jahr {{balance.begin.year}}.<br />Freundliche Grüße<br />...</p>{% endblock %}
{% block heading %}{% endblock %}
{% block email_link %}{% endblock %}
Ein Link zur Webansicht wird durch die Anpassung eines Snippets in der Admin-Ansicht einer Nebenkostenabrechnung eingeblendet:
# templates/admin/services/balance/change_form_object_tools.html
{% extends "admin/change_form_object_tools.html" %}
{% load i18n admin_urls %}
{% block object-tools-items %}
<li>
<a href="{% url 'display_balance' balance_id=original.pk %}" class="historylink">{% translate "View" %}</a>
</li>
<li>
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% translate "History" %}</a>
</li>
{% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% translate "View on site" %}</a></li>{% endif %}
{% endblock %}
Eine dritte Ansicht dient ausschließlich der Diskretion. Digitale Rechnungen im PDF-Format sind ein optionaler Bestandteil jeder Invoice
-Instanz:
class Invoice(models.Model):
external_id = models.CharField('Rechnungsnummer', max_length=30)
contract = models.ForeignKey(Contract, verbose_name='Vertrag', on_delete=models.CASCADE)
date = models.DateField('Rechnungsdatum')
begin = models.DateField('Rechnungszeitraum (Beginn)')
end = models.DateField('Rechnungszeitraum (Ende)')
amount = models.DecimalField('Betrag', max_digits=8, decimal_places=2)
consumption = models.DecimalField('Leistung/Verbrauch', max_digits=10, decimal_places=3, blank=True, null=True)
counter_consumption = models.DecimalField('Verbrauch laut Zähler', max_digits=10, decimal_places=3, blank=True, null=True)
invoice_file = models.FileField('Rechnungskopie', upload_to='invoices', blank=True, null=True)
Die Ablage (und Verlinkung) dieser Dateien wird in settings.py
konfiguriert:
MEDIA_ROOT = '/Users/snafu/projects/landlord/media'
MEDIA_URL = ''
Damit Nginx diese Dateien nicht ohne Rücksprache mit Django ausliefert, kommt die Direktive internal
zum Einsatz:
# nginx.conf
location ~ ^/media/ {
internal;
}
Django seinerseits verarbeitet URLs der Form /invoices/<file_name>
und ermöglicht die Anzeige von /media/invoices/<file_name>
:
# ../landlord/urls.py
from services.views import invoice_file
urlpatterns = [
path('services/', include('services.urls')),
path('invoices/<file_name>', invoice_file, name="invoice_file"),
path('admin/', admin.site.urls),
]
# views.py
@login_required
def invoice_file(request, file_name):
response = HttpResponse()
response['Content-Type'] = 'application/pdf'
response['X-Accel-Redirect'] = '/media/invoices/' + file_name
response['Content-Disposition'] = 'inline;filename=' + file_name
return response
Ein wesentlicher Vorzug des Django-Frameworks ist die mitgelieferte Administrationsoberfläche, und davon profitiert auch meine Miethai-Anwendung. Die Verwaltung der Gebäude umfasst auch die einzelnen Wohnungen und Dienstleistungen. Weil die Entfernung/Änderung einer bestehenden Wohnung/Dienstleistung weitreichende Folgen für bestehende Abrechnungen hat, sind die Funktionen an dieser Stelle deaktiviert:
class UnitInlineAdmin(admin.TabularInline):
model = Unit
extra = 0
ordering = ('location',)
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
class ServiceInlineAdmin(admin.TabularInline):
model = Service
extra = 0
ordering = ('name',)
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
class BuildingAdmin(admin.ModelAdmin):
list_display = ('name', 'address',)
readonly_fields = ('area',)
inlines = [UnitInlineAdmin,ServiceInlineAdmin,]
Für Verträge wird über die clean_services()
-Methode sichergestellt, dass sich alle zugeordneten Dienstleistungen auf dasselbe Gebäude beziehen und dass die Summe ihrer Rechnungsanteile 1 (100%) beträgt:
class ContractForm(forms.ModelForm):
def clean_services(self):
if sum(service.share_of_invoice for service in self.cleaned_data['services']) != 1:
raise forms.ValidationError(_('Summe der Rechnungsanteile ist != 1.'), code='invalid_invoice_share')
building = self.cleaned_data['services'].first().building
for service in self.cleaned_data['services']:
if service.building != building:
raise forms.ValidationError(_('Dienste für unterschiedliche Gebäude ausgewählt!'), code='different_buildings')
return self.cleaned_data['services']
class ContractAdmin(admin.ModelAdmin):
list_display = ('external_id', 'contractor', 'get_services',)
filter_horizontal = ('services', )
save_as = True
form = ContractForm
inlines = [InvoiceInlineAdmin,]
Ebenfalls abgesichert wird die Belegung von Wohnungen, um überlappende und unlogische Einträge zu verhindern:
class OccupancyInlineFormset(forms.BaseInlineFormSet):
def clean(self):
super().clean()
for form in self.forms:
unit = form.cleaned_data['unit']
begin = form.cleaned_data['begin']
end = form.cleaned_data['end']
if end < begin:
raise forms.ValidationError(_('Fehlerhafte Datumsreihenfolge!'), code='wrong_order')
conflicts = Occupancy.objects.filter(models.Q(unit=unit), models.Q(begin__lte=begin, end__gte=begin) | models.Q(begin__lte=end, end__gte=end) | models.Q(begin__gte=begin, end__lte=end)).exclude(pk=form.instance.id)
if conflicts:
raise forms.ValidationError(_('Zeitliche Überschneidung mit bestehender Belegung!'), code='overlap')
class OccupancyAdmin(admin.TabularInline):
model = Occupancy
extra = 0
ordering = ('begin',)
formset = OccupancyInlineFormset
class UnitAdmin(admin.ModelAdmin):
list_display = ('location', 'building', 'id')
search_fields = ['building__name', 'location']
inlines = [OccupancyAdmin,]
Dasselbe Prinzip wird für Mietverträge angewendet. Zwischenzeitlich war ich der Illusion verfallen, ich könne beim Eintrag einer neuen Belegung (occupancy.end == datetime.date(9999,12,31)
) automatisch die aktuelle Belegung auf den Vortag befristen:
class OccupancyInlineFormset(forms.BaseInlineFormSet):
def clean(self):
super().clean()
infinity = datetime.date(9999,12,31)
for form in self.forms:
unit = form.cleaned_data['unit']
begin = form.cleaned_data['begin']
end = form.cleaned_data['end']
if end < begin:
raise forms.ValidationError(_('Fehlerhafte Datumsreihenfolge!'), code='wrong_order')
conflicts = Occupancy.objects.filter(models.Q(unit=unit), models.Q(begin__lte=begin, end__gte=begin) | models.Q(begin__lte=end, end__gte=end) | models.Q(begin__gte=begin, end__lte=end)).exclude(pk=form.instance.id).exclude(end=infinity)
if conflicts:
raise forms.ValidationError(_('Zeitliche Überschneidung mit bestehender Belegung!'), code='overlap')
elif end == infinity:
try:
current_occupancy = Occupancy.objects.get(unit=unit, end=datetime.date(9999,12,31))
if current_occupancy.pk != form.instance.id:
current_occupancy.end = begin - datetime.timedelta(days=1)
current_occupancy.save()
except ObjectDoesNotExist:
pass
Mit dieser Variante war es allerdings möglich, Überschneidungen mit der aktuellen Belegung zu erzeugen, eine noch komplexere clean()
-Methode widerstrebt mir und die Eingabe neuer Belegungen in zwei Schritten ist auch nicht unzumutbar. Eine Alternative zur Formularvalidierung ist die Verwendung von Datenbank-Restriktionen für die Modelle:
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateRangeField, RangeBoundary, RangeOperators
class DateRangeFunc(models.Func):
function = 'daterange'
output_field = DateRangeField()
class Occupancy(models.Model):
class Meta:
constraints = [
ExclusionConstraint(
name="exclude_overlapping_occupancies",
expressions=(
(
DateRangeFunc(
"begin", "end", RangeBoundary(inclusive_lower=True, inclusive_upper=True)
),
RangeOperators.OVERLAPS,
),
("unit", RangeOperators.EQUAL),
),
),
]
Die verwendete PostgreSQL-Datenbank muss auf diese Herangehensweise vorbereitet sein (CREATE EXTENSION btree_gist;
), um eine pikierte Fehlermeldung –
django.db.utils.ProgrammingError: data type bigint has no default operator class for access method "gist"
HINT: You must specify an operator class for the index or define a default operator class for the data type.
– zu vermeiden. Leider greift ein funktionierender ExclusionConstraint
anders als erwartet nicht erst im Rahmen der Speicherung einer Instanz (save()
), sondern bereits vor der Formvalidierung mittels clean()
, so dass Überschneidungen statt des erwarteten ValidationError
stets einen IntegrityError
produzieren.
Nach rund 10 Jahren bastele ich erstmals eine neue Django-App, diesmal geht es um Nebenkostenabrechnungen und damit erneut um Zeiträume. Datumskalkulationen sind offenbar mein Schicksal. Das Datenmodell ist auf den ersten Blick recht einfach: Jedes Gebäude besteht aus einer Anzahl Wohnungen. Mit jeder Wohnung ist zu jedem Zeitpunkt ein Mietvertrag (und indirekt eine Mieterin) sowie eine Reihe von Dienstleistungen (Heizung, Abwasser etc) verbunden. Für jede Dienstleistung gibt es einen Vertrag, dem Rechnungen zugeordnet sind. Außerdem sind in jeder Wohnung Zähler verbaut, die den Verbrauch für bestimmte Services messen. Jede Nebenkostenabrechnung enthält Abrechnungsposten, die sich jeweils auf einen Service und die zugehörige Rechnung beziehen.
Ganz so einfach ist es leider nicht, denn einige Dienstleistungen werden aufgeteilt und nach unterschiedlichen Schlüsseln abgerechnet. Entsprechend muss die Verbindung zwischen Contract
und Service
als ManyToManyField
abgebildet und für jeden Service ein Rechnungsanteil (<= 100%) ausgewiesen werden:
Erschwerend kommt hinzu, dass in einem Fall die Einheit der Zählerstände nicht identisch ist mit den in Rechnung gestellten Verbrauchseinheiten – der Warmwasserverbrauch wird über die Wassermenge (in m³) erfasst, aber über die eingesetzte Energiemenge (kWh) abgerechnet. Entsprechend muss der Rechnung eine Information über den Verbrauch hinzugefügt werden:
class Invoice(models.Model):
consumption = models.DecimalField('Leistung/Verbrauch', max_digits=10, decimal_places=3, blank=True, null=True)
counter_consumption = models.DecimalField('Verbrauch laut Zähler', max_digits=10, decimal_places=3, blank=True, null=True)
Tatsächlich ist die Handhabung bestimmter Rechnungen noch komplexer: Die gelieferte Gasmenge wird für Heizung und Warmwasserbereitung eingesetzt und muss entsprechend nach Verbrauch aufgeteilt werden, bevor der feste Schlüssel (share_of_invoice
) für die Grund- und Verbrauchsanteile der Heizkosten greift. Die Berücksichtigung dieses mehrstufigen Verfahrens würde das Datenmodell aber so sehr verkomplizieren, dass ich die Gasrechnungen im ersten Schritt manuell und als Teilrechnungen (Heizung/Warmwasser) erfasse.
Der wesentliche Teil der business logic für die Abrechnungen steckt im Service
-Modell:
class Service(models.Model):
SERVICE_TYPE_CHOICES = [
('AR', 'Wohnfläche'),
('OC', 'Personen'),
('CO', 'Verbrauch'),
]
service_type = models.CharField(max_length=2, choices=SERVICE_TYPE_CHOICES, default='AR')
MEASUREMENT_UNIT_CHOICES = [
('NA', 'n/a'),
('KW', 'kWh'),
('CB', 'm³'),
]
measurement_unit = models.CharField(max_length=2, choices=MEASUREMENT_UNIT_CHOICES, default='NA')
building = models.ForeignKey(Building, verbose_name='Gebäude', on_delete=models.CASCADE)
name = models.CharField('Bezeichnung', max_length=100)
share_of_invoice = models.DecimalField('Rechnungsanteil', max_digits=4, decimal_places=3, default=1)
Jedem service_type
entspricht ein proxy model, dem das betreffende Objekt während der Initialisierung zugeordnet wird:
def __init__(self, *args, **kwargs):
super(Service, self).__init__(*args, **kwargs)
subclasses = {
'AR' : AreaService,
'OC' : OccupancyService,
'CO' : ConsumptionService,
}
if self.pk:
self.__class__ = subclasses[self.service_type]
Die Kosten für einen AreaService
werden entsprechend der anteiligen Wohnfläche umgelegt, für einen OccupancyService
ist die Anzahl der Personen ausschlaggebend und für den ConsumptionService
der tatsächliche Verbrauch.
Jedes der drei proxy models verwendet individuelle Versionen der Manager
-Schnittstelle und der save()
-Methode, um für Konsistenz zu sorgen:
class AreaServiceManager(models.Manager):
def get_queryset(self):
return super(AreaServiceManager, self).get_queryset().filter(service_type='AR')
def create(self, **kwargs):
kwargs.update({'service_type': 'AR'})
return super(AreaServiceManager, self).create(**kwargs)
class AreaService(Service):
objects = AreaServiceManager()
def save(self, *args, **kwargs):
self.service_type = 'AR'
return super(AreaService, self).save(*args, **kwargs)
class Meta:
proxy = True
Das Modell für eine Nebenkostenabrechnung enthält nur wenige Felder, aber eine umfangreiche save()
-Methode. Balance.save()
nimmt zunächst Bezug auf alle Rechnungen im Abrechnungszeitraum, und ruft für jede Rechnung die create_balance_entries()
-Methode aller Service
-Objekte auf, die – vermittelt über einen Vertrag – mit der Rechnung verknüpft sind:
class Balance(models.Model):
tenancy_agreement = models.ForeignKey(TenancyAgreement, verbose_name='Mietvertrag', on_delete=models.CASCADE)
begin = models.DateField('Abrechnungszeitraum (Beginn)')
end = models.DateField('Abrechnungszeitraum (Ende)')
date = models.DateField('Abrechnungsdatum', auto_now_add=True)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
invoices = Invoice.objects.filter(models.Q(contract__services__building=self.tenancy_agreement.unit.building), models.Q(begin__lte=self.begin, end__gt=self.begin) | models.Q(begin__gt=self.begin, begin__lt=self.end)).distinct()
existing_entries = BalanceEntry.objects.filter(balance=self)
if invoices and not existing_entries:
entries = []
for invoice in invoices:
for service in invoice.contract.services.all():
print('Creating entry...')
entries = entries + service.create_balance_entries(invoice, self)
for service, invoice, service_share, service_total, number_of_days, share_of_invoice in entries:
print('Saving entry...')
balance_entry = BalanceEntry(balance=self, service=service, invoice=invoice, days_share=number_of_days, service_share=service_share, service_total=service_total, share_of_invoice=share_of_invoice)
balance_entry.save()
Die AreaService
-Variante von create_balance_entries()
ist am simpelsten, da sie nur die zeitliche Überlappung von Rechnung und Nebenkostenabrechnung errechnen muss:
def create_balance_entries(self, invoice, balance):
latest_start = max(invoice.begin, balance.begin)
earliest_end = min(invoice.end, balance.end)
delta = (earliest_end - latest_start).days + 1
number_of_days = max(0, delta)
return [(self, invoice, balance.tenancy_agreement.unit.area, self.building.area, number_of_days, self.share_of_invoice)]
Für einen OccupancyService
muss dagegen berücksichtigt werden, dass (theoretisch) an jedem einzelnen Tag eine andere Relation von anteiliger Belegung und Gesamtbelegung existieren kann, und entsprechend mehrere Abrechnungsposten im Abrechnungszeitraum entstehen können:
def create_balance_entries(self, invoice, balance):
latest_start = max(balance.begin, invoice.begin)
earliest_end = min(balance.end, invoice.end)
occupancy_dict = {}
current_day = latest_start
while current_day <= earliest_end:
if balance.tenancy_agreement.unit.occupancy_key(current_day) in occupancy_dict:
occupancy_dict[balance.tenancy_agreement.unit.occupancy_key(current_day)][2] += 1
else:
occupancy_dict[balance.tenancy_agreement.unit.occupancy_key(current_day)] = [
balance.tenancy_agreement.unit.occupancy_unit(current_day),
balance.tenancy_agreement.unit.occupancy_building(current_day),
1
]
current_day += datetime.timedelta(days=1)
entries = []
for occupancy_share, occupancy_values in occupancy_dict.items():
unit_occupancy = occupancy_values[0]
building_occupancy = occupancy_values[1]
number_of_days = occupancy_values[2]
entries.append((self, invoice, unit_occupancy, building_occupancy, number_of_days, self.share_of_invoice))
return entries
Ein verbrauchsbezogener ConsumptionService
schließlich muss die Zählerstände für die einzelnen Wohnungen berücksichtigen und kann die zeitliche Dimension ignorieren:
def create_balance_entries(self, invoice, balance):
counters = Counter.objects.filter(service=self, unit=balance.tenancy_agreement.unit)
consumption = 0
for counter in counters:
latest = CounterValue.objects.filter(counter=counter).latest('date')
try:
baseline = CounterValue.objects.get(counter=counter, current_baseline=True)
print(f'Adding difference between {latest.value} and {baseline.value}')
consumption += (latest.value - baseline.value)
baseline.current_baseline=False
baseline.save()
except ObjectDoesNotExist:
consumption += latest.value
latest.current_baseline=True
latest.save()
if invoice.counter_consumption:
consumption = consumption / invoice.counter_consumption * invoice.consumption
number_of_days = invoice.days
return [(self, invoice, consumption, invoice.consumption, number_of_days, self.share_of_invoice)]
Der Verbrauch wird aus der Summe der Differenzen zwischen Ausgangswert und aktuellem Zählerstand ermittelt und (ggf.) in die in Rechnung gestellte Einheit umgerechnet (m³ → kWh). Weil die aktuellen Zählerstände im Zuge der Berechnung als die neuen Ausgangswerte markiert werden, kann eine Nebenkostenabrechnung nur einmal erstellt werden. Die Berechnung des Rechnungsanteils funktioniert außerdem nur, wenn für jeden ConsumptionService
im Abrechnungszeitraum genau eine Rechnung vorliegt, auf die der Verbrauch anhand der Zählerstände bezogen werden kann.
Dieselbe Partei, die Anfang 2023 zum Gedenken an die Märzrevolution von 1848 einlädt (175 Jahre Revolution in Europa – ein guter Grund zu feiern!
), übergibt die Verarbeitung geänderter Bankverbindungen aus dem bundesweiten Online-Portal an die mitgliederpflegende Stelle (den zuständigen Landesverband), der das Mitglied pflichtschuldig über die Änderung am SEPA-Lastschriftmandat informiert.
Nachdem mich die ständigen Unannehmlichkeiten in Folge von Homebrew-Updates mürbe gemacht haben, liefert PEP 704 den Anlass, endlich eine virtuelle Umgebung für meine Django-Projekte einzurichten:
$ python3 -m venv virtualdjango
$ source virtualdjango/bin/activate
(virtualdjango) $ pip install Django
(virtualdjango) $ pip install Django
(virtualdjango) $ pip install uwsgi
(virtualdjango) $ pip install psycopg2
(virtualdjango) $ pip install keyring
(virtualdjango) $ pip install gnupg
(virtualdjango) $ pip freeze > ~/Sites/virtualdjango_requirements.txt
(virtualdjango) $ deactivate
Die Aktivierung der virtuellen Umgebung scheitert zwar im ersten Anlauf, weil ich auf die glorreiche Idee gekommen bin, in .zshrc
das Alias hash
für eines meiner Skripte zu definieren, und das activate
-Skript die Existenz der gleichnamigen Shell-Funktion überprüft:
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi
Anschließend beschränkt sich die Nutzung der neuen Umgebung aber – zu meiner Überraschung – auf die Anpassung einer einzigen Zeile in ~/Sites/djangoapp/manage.py
–
#!/Users/snafu/virtualdjango/bin/python3
– sowie die Überarbeitung des LaunchAgent-Konfiguration für uwsgi:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>net.janeden.uwsgi</string>
<key>ProgramArguments</key>
<array>
<string>/Users/snafu/virtualdjango/bin/uwsgi</string>
<string>--uid</string>
<string>_www</string>
<string>--gid</string>
<string>_www</string>
<string>--master</string>
<string>--die-on-term</string>
<string>--autoload</string>
<string>--logto</string>
<string>/Users/snafu/Library/Logs/Django/uwsgi.log</string>
<string>--emperor</string>
<string>/Users/snafu/Sites/djangoapp</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/snafu/virtualdjango</string>
</dict>
</plist>
Dank Python 3.11 ist die virtuelle Django-Umgebung nicht nur stabiler, sondern auch substantiell schneller:
Server Software: |
nginx 1.23.1 / Python 3.9 |
nginx 1.23.3 / Python 3.11 |
Server Port: |
80 |
80 |
Document Path: |
/ |
/ |
Document Length: |
65820 bytes |
65820 bytes |
Concurrency Level: |
50 |
50 |
Time taken for tests: |
1.595 seconds |
0.999 seconds |
Complete requests: |
500 |
500 |
Failed requests: |
0 |
0 |
Total transferred: |
33065000 bytes |
33065000 bytes |
HTML transferred: |
32910000 bytes |
32910000 bytes |
Requests per second: |
313.49 [#/sec] (mean) |
500.27 [#/sec] (mean) |
Time per request: |
159.492 [ms] (mean) |
99.945 [ms] (mean) |
Time per request: |
3.190 [ms] (mean, across all concurrent requests) |
1.999 [ms] (mean, across all concurrent requests) |
Transfer rate: |
20245.53 [Kbytes/sec] received |
31108.88 [Kbytes/sec] received |
Die Kombination von leistungsfähiger Suchfunktion und undifferenzierter Ordnerstruktur ermöglicht es mir, auf eine ordnerübergreifende Suche weitgehend zu verzichten. Für besondere Anlässe – wie die Suche nach einer mehrere Jahre zurückliegenden Konversation – greife ich auf notmuch zurück:
brew install notmuch
brew install notmuch-mutt
# ~/.muttrc
macro index <F8> "<enter-command>unset wait_key<enter><shell-escape>/opt/homebrew/bin/notmuch-mutt --prompt search<enter><change-folder-readonly>/Users/snafu/.cache/notmuch/mutt/results<enter>" "search mail (using notmuch)"
macro index <F9> "<enter-command>unset wait_key<enter><pipe-message>/opt/homebrew/bin/notmuch-mutt thread<enter><change-folder-readonly>/Users/snafu/.cache/notmuch/mutt/results<enter><enter-command>set wait_key<enter>" "search and reconstruct owning thread (using notmuch)"
Weil isync/mbsync über den von notmuch verwendeten Nachrichtenindex stolpert (Maildir error: found subfolder '.notmuch/xapian', but store 'personal-local' does not specify SubFolders style
), muss auch .mbsyncrc
ergänzt werden:
# ~/.mbsyncrc
MaildirStore personal-local
Path ~/PersonalMail/
Inbox ~/PersonalMail/INBOX
Subfolders Verbatim
Das Perl-Skript notmuch-mutt create[s] a virtual
maildir folder with search results whenever a search is made
, und abgesehen von der geringfügig anderen Syntax für Suchbegriffe ist die Integration von mutt und notmuch sehr gelungen.
Einige Monate, nachdem ich das Thema DMARC für mich abgeschlossen habe, rächt sich mein oberflächliches Verständnis der DMARC-Prinzipien. Als ich zum ersten Mal einen Blick auf einen DMARC-Report von Google für lists.eden.one
werfe, bin ich sowohl erschüttert als auch verwirrt:
<record>
<row>
<source_ip>123.123.123.123</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>lists.eden.one</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>lists.eden.one</domain>
<result>pass</result>
<selector>s42</selector>
</dkim>
<spf>
<domain>eden.one</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
Warum ist der SPF-Test gleichzeitig erfolgreich und nicht erfolgreich? Einen ersten Hinweis liefert der etwas detailliertere Report eines anderen Providers:
<record>
<row>
<source_ip>123.123.123.123</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>lists.eden.one</header_from>
<envelope_from>eden.one</envelope_from>
</identifiers>
<auth_results>
<dkim>
<domain>janeden.net</domain>
<selector>s42</selector>
<result>pass</result>
</dkim>
<spf>
<domain>eden.one</domain>
<scope>mfrom</scope>
<result>pass</result>
</spf>
</auth_results>
</record>
Die Ursache ist offensichtlich die Abweichung zwischen header_from
und envelope_from
durch die Verwendung des Sender Rewriting Scheme auf meinem Mailserver. Während der SPF-Test selbst lediglich den relevanten DNS-Eintrag für die Briefumschlagsdomain (eden.one
) berücksichtigt, prüft der DMARC-Mechanismus in Bezug auf SPF, ob header_from
und envelope_from
zueinander passen (Under DMARC a message can fail even if it passes SPF or DKIM, but fails alignment.
). Weil meine DMARC-Policy für SPF (und DKIM) eine strikte Übereinstimmung fordert –
<policy_published>
<domain>lists.eden.one</domain>
<adkim>s</adkim>
<aspf>s</aspf>
<p>quarantine</p>
<sp>quarantine</sp>
<pct>75</pct>
</policy_published>
– muss dieser alignment check für SPF scheitern. DKIM ist nicht betroffen, weil Listenmails eine eigene DKIM-Signatur für lists.eden.one
erhalten. Für Subdomains wie lists.eden.one
lässt sich das SPF-Problem lösen, indem ich auf eine entspanntere Policy wechsele:
<policy_published>
<domain>lists.eden.one</domain>
<adkim>s</adkim>
<aspf>r</aspf>
<p>quarantine</p>
<sp>quarantine</sp>
<pct>75</pct>
</policy_published>
<record>
<row>
<source_ip>217.160.240.138</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
Für andere Domains (z.B. janeden.net
) käme nur ein Verzicht auf SRS in Frage, um die Abweichung von header_from
und envelope_from
zu eliminieren –
<record>
<row>
<source_ip>123.123.123.123</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>janeden.net</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>janeden.net</domain>
<result>pass</result>
<selector>s42</selector>
</dkim>
<spf>
<domain>janeden.net</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
– was aber neue Probleme für die vielen über meinen Mailserver weitergeleiteten E-Mails aufwürfe. Ich tröste mich damit, dass das passende DKIM-Alignment eine hinreichende Bedingung für ein positives DMARC-Ergebnis ist.
Seit mehr als zehn Jahren beobachte ich mit einer Mischung aus zusammengebissenen Zähnen und Gleichmut, dass Listen innerhalb von Zitaten (<blockquote><ol><li>...</li></ol></blockquote>
) auf meiner Website sehr unschön dargestellt werden. Dafür verantwortlich sind die folgenden CSS-Anweisungen, die die Zitatbegrenzungslinie zerhacken:
blockquote > ul, blockquote > ol
{
padding: 1em 2em 1em 2em;
border-width: 0px 0px 0px 1px;
border-color: #369;
border-style: solid;
}
ul, ol
{
margin-left: 3em;
margin-bottom: 0.8em;
}
Es spricht nicht für meinen gestalterischen Willen, dass ich die triviale Lösung –
blockquote > ul, blockquote > ol
{
padding: 1em 2em 1em 2em;
margin-left: 0em;
margin-bottom: 0em;
border-width: 0px 0px 0px 1px;
border-color: #369;
border-style: solid;
}
blockquote > ul > li, blockquote > ol > li
{
margin-left: 3em;
}
ul, ol
{
margin-left: 3em;
margin-bottom: 0.8em;
}
– erst heute implementiere.
Der gestrenge Herr Reiter mahnt zum neuen Jahr –
Friends of GnuPG,
a happy new year to all of you!
Now I am taking Andrew (hi) as an example to send a reminder why using text/plain format only mails is a good idea on this (and other mailing lists).
Am Samstag 17 Dezember 2022 19:54:39 schrieb Andrew Gallagher via Gnupg-users:
> <html><head><meta http-equiv="content-type" content="text/html;
> charset=utf-8"></head><body dir="auto">I’ve been
Because HTML can have a lot of active contents, a number of people I know sanitize email that have text/html parts. Some ignore such emails completely.
In the past I know that Werner ignored (most) emails with text/html.
There are more advanted [sic!] to text/plain mails:
- people can better chose how their email client is displaying the contents, for instance the font size and color.
- it saves energy because of less bytes transmitted and backuped (and indexed, archived and searched).
– und verweist auf die
productivity gap between people that use full fledged and customised emails clients to those with only web and mobile clients. As email is one of the working decentralised communication solutions, I think we should value it more and thus help people to learn about the productivity of an email client that they can fully control (on their hardware) and customize to have one unified interface to several communities.
Der missionarische Eifer meiner Mitgeeks rührt mich sehr.
Ein knappes halbes Jahr nach der Veröffentlichung von Vim 9.0 widme ich mich endlich dem Upgrade von .vimrc
auf Vim9 Script. Auf den ersten Blick sind das neue Kommentarzeichen, die geänderte Variablendeklaration und die vereinfachte Funktionssyntax keine großen Herausforderungen, aber der Teufel steckt im Detail. Die Angabe von ranges für Kommandos erfordert nun ein Präfix (:
), und in many places
ist Weißraum obligatorisch (während er beim Setzen von Optionen – set formatoptions+=n
– nach wie vor untersagt ist).
Ich nutze die Gelegenheit, um meine überbordende .vimrc
mit Hilfe von filetype plugins etwas zu verschlanken und bei dieser Gelegenheit die unterschiedlichen Mappings für Markup-Sprachen zu vereinheitlichen:
# ~/.vim/ftplugins/markdown.vim
vnoremap <buffer> <silent> ,b <Esc>`>a**<Esc>`<i**<Esc>
vnoremap <buffer> <silent> ,a <Esc>`>a]()<Esc>P`<i[<Esc>
# ~/.vim/ftplugins/html.vim
vnoremap <buffer> ,a <Esc>`>a</a><Esc>`<i<a href="<Esc>pa"><Esc>
vnoremap <buffer> ,b <Esc>`>a</strong><Esc>`<i<strong><Esc>
Besonders tückisch ist, dass html.vim
auch für Markdown-Buffer geladen wird (mit sehr verwirrenden Konsequenzen), wenn man nicht frühzeitig eingreift:
# ~/.vim/ftplugins/html.vim
if &ft == "markdown"
finish
endif
Ebenfalls nicht trivial ist die Handhabung des substitute
-Kommandos im virtual mode. Einfache Funktionen könnten im Prinzip unmittelbar als Mapping realisiert werden:
def g:AddLinebreaks()
silent s/.\zs\n\ze./\<br \/\>\r/ge
nohl
enddef
vnoremap <f3> :call AddLinebreaks()<CR>
Interessant wird es, wenn ein Kommando innerhalb der Funktion die Markierung als Ganze behandeln soll, während das substitute
-Kommando auf jeder Zeile operieren muss:
def g:EncloseParagraphs()
execute "normal `>a</p>\<Esc>`<i<p>\<Esc>"
silent :%s/\%V\n\{2,}\%V/<\/p>\r\r<p>/ge
nohl
enddef
vnoremap <buffer> <silent> ,p :<C-U>call EncloseParagraphs()<CR>
Mittels <C-U> wird verhindert, dass die gesamte Funktion (einschließlich des execute
-Kommandos) für jede Zeile innerhalb der Markierung ausgeführt wird. Das substitute
-Präfix :%
verschafft diesem Kommando dann eine maximale Reichweite (alle Zeilen des Buffers), die schließlich durch das Musteratom \%V
wieder auf die ursprüngliche Markierung begrenzt wird.
Diep Pham bringt es auf den Punkt:
Q: Is it worth converting all my vim scripts to Vim9 script?
A: IMO, no. The syntax looks a little better to read and write, but not by a huge margin. But if you have some free time, why not?
Im Windschatten von Mastodon und angesichts des Digital Markets Act rückt Paul Sawers auf TechCrunch das Matrix-Protokoll ins Rampenlicht und überhöht die (unfertige, halbherzige, komplizierte) Matrix-Unterstützung von Rocket.Chat ein wenig:
Back in May, open source enterprise messaging platform Rocket.Chat revealed that it would be transitioning to the Matrix protocol. While this process is still ongoing, this represented a major coup for the Matrix movement, given that Rocket.Chat claims some 12 million users across major organizations such as Audi, Continental and Germany’s national railway company, The Deutsche Bahn.
In diesem Sinne leitete Apple mit iTunes for Windows auch den Wechsel auf die Windows-Plattform ein.
Nach einer gewissen Abklingzeit versuche ich mich noch einmal an der Installation eines CLI-Clients für Matrix und siehe da – ein rabiates Upgrade der Xcode Command Line Tools löst das Problem:
sudo rm -rf /Library/Developer/CommandLineTools
sudo xcode-select --install
brew tap aaronraimist/tap
brew cask install gomuks
Um die bestehenden (verschlüsselten) Konversationen lesen zu können, muss ich lediglich die Raumschlüssel aus Element exportieren und in gomuks importieren (/import /Users/snafu/Desktop/room_keys.txt; /clearcache
). Die anschließende Verifikation der neuen gomuks-Session anhand ihres Fingerabdrucks (/fingerprint
) funktioniert natürlich etwas anders als mit einem GUI-Client:
To find the manual verify option in Element, click on that user in the list of users or click their profile picture in the timeline. That will open their profile. Then click on the device you want to verify and then click the manually verify button.
Mein Dank gilt Aaron Raimist für die Zapfanlage und den individuellen Support.
Die Mailman Suite zeichnet sich nicht nur durch eine komplexe Architektur, sondern auch durch viele kleine und leicht zu behebende Fehler aus. Neben der falschen Platzierung von Konfigurationsdateien, über die ich bei der Installation gestolpert bin, funktioniert auch der Export von Listenmitgliedern per Kommandozeile (mailman members listname@domain --output list_members.txt
) nur dann einwandfrei, wenn die display names sich auf den ASCII-Zeichensatz beschränken. Andernfalls: =?utf-8?q?J=C3=BCrgen?=
.
Mark Sapiro stellt natürlich innerhalb weniger Stunden einen Patch bereit, muss aber kurz darauf ein Python-Skript zur Korrektur von display names nachliefern, die mit Mailman < 3.3.3 erstellt (und daher rfc2047-encoded gespeichert) wurden. Er räumt auch freimütig ein, dass eine Bearbeitung von display names eigentlich via Postorius möglich sein sollte. Aber:
Understood, but there are a couple of things at work here. One is that this particular task is a one-off the correct the result of a bug, but perhaps more significantly, mailman developers (at least me) are comfortable with withlist
(it's been around since MM 2.1) and if I can do it that way, it reduces the motivation to make something more friendly for the normal admin. Unfortunately doing things easily that way requires a lot of specialized knowledge of Mailman internals, so most admins need help with it.
Diese Graubärtigkeit ist mir sehr sympathisch, auch wenn sie mit meiner dauerhaften Abhängigkeit von mailman-users[at]mailman3.org einhergeht. Immerhin hat das heutige Upgrade auf die aktuellen Mailman-Komponenten ohne Rücksprache mit Mark funktioniert:
systemctl stop mailman3
systemctl stop mailmanweb
su - mailman
pip3 install -U mailman postorius django-mailman3 hyperkitty mailman-web
mailman-web migrate
mailman-web compress
mailman-web collectstatic
mailman-web compilemessages
exit
systemctl start mailman3
systemctl start mailmanweb
Offenbar gab es im LastPass-Management einen Fuck Everything-Moment und man hat entschieden, die lauwarmen Reaktionen auf diverse Vorfälle nicht länger zu tolerieren.
Mit der umfassenden Übermittlung von teilweise unverschlüsselten oder wenigstens entschlüsselbaren Kundendaten an Dritte sollte die Diskussion darüber, welcher Passwortmanager die Prinzipien von Openness und Sharing am konsequentesten vertritt, beendet sein (zumal ein kanadischer Konkurrent keinen Hehl aus seiner übervorsichtigen Verschlüsselungsarchitektur macht). Auch die Presse und Sicherheitsexpertinnen können nicht umhin, die Bemühungen von LastPass endlich ernst zu nehmen.
Für weitergehende Ambitionen bleibt eigentlich nur der unbegrenzte Erwartungshorizont der Nutzerinnen:
Here’s what I want from a password manager:
- Stores my passwords in the caldera of an active volcano.
- I can access it with a 6-digit PIN from any computer on the Internet.
I don’t understand why this is so hard for the industry to get right.
Dem Präsidenten des Neusser Bürger-Schützen-Vereins e.V., Martin Flecken, ist es auf unerklärliche Weise gelungen, sämtliche Debatten zu Genderfragen in den vergangenen Jahren zu ignorieren. Die Reaktionen auf die Entscheidung, weiterhin keine Frauen als Vereinsmitglieder zuzulassen, kommentiert er verwundert:
Ich dachte, über so ein sachliches Thema kann man ja eigentlich nicht so heftig diskutieren.
Diesen Grad der Weltabgewandtheit sollte man eigentlich nur bei Karthäusermönchen erwarten.
Ich bewundere Dr. Drangs disziplinierte und detaillierte Dokumentation seiner Datenreinigungsabenteuer und kann sie manchmal sogar in kleinem Rahmen nachstellen. Für die Extraktion von Namen und E-Mail-Adressen aus einer VCF-Datei benötigt man nämlich nicht unbedingt ein komplexes Python-Modul. Die doppelte Anwendung des regulären Ausdrucks
FN:(.*)\nEMAIL.*pref:(.*)
in Verbindung mit den BBEdit-Befehlen Extract und Replace all (\1 <\2>
) funktioniert einwandfrei, und Kontakte ohne primäre E-Mail-Adresse sind auch rasch extrahiert (FN:(.*)\n(?!EMAIL)
).
Trotz meiner viel beschworenen Begeisterung für das maximum viable product vim habe ich bis heute die falsche Tastenbelegung genutzt, weil die entsprechenden Anweisungen –
# .bashrc
set -o vi
# .zshrc
bindkey -v
– einen Blindflug zwischen insert mode und normal mode initiieren. Das muss natürlich nicht sein:
brew install zsh-vi-mode
echo "source $(brew --prefix)/opt/zsh-vi-mode/share/zsh-vi-mode/zsh-vi-mode.plugin.zsh" >> .zshrc