nikclayton

Mastodon: https://mastodon.social/@nikclayton

People have forgotten what code review is for.

Code review is an asynchronous alternative to pair programming. Like pair programming the goal is that more than one person knows how the code works, understands the design, and it acts as a check-of-last-resort that the design is sound and the code implements the design.

So if you've been sent a multi-thousand line diff to review you're perfectly within your rights to tell the author to book a meeting with you so they can explain the code.

If done well this is much faster than repeatedly round-tripping code reviews in whatever tool you're using.

After yesterday's post about finding snowshoe routes using Swiss OpenData and QGIS I had one open question: how to also filter the routes by their minimum elevation?

As winter turns to spring and the snow starts melting this is necessary to find routes that probably still have snow on them.

The data from opendata.swiss has the route geometry, and various pre-existing fields for the route length, maximum ascent, and so on, but there is no direct field that contains the route's minimum elevation, so it cannot be used directly in a filter.

Assuming that this is the sort of thing that must be both possible and straightforward, I asked the QGIS community on the Fediverse. @tlohde@fediscience.org was kind enough to reply, and this post is adapted from their suggestions.

The rest of this post assumes you have already followed the steps in Finding snowshoe routes using Swiss OpenData and QGIS.

Outline

We are going to:

  1. Create a new column in the snowshoe data to contain the minimum elevation of each route.
  2. Apply a QGIS function, z_min, to populate this column.
  3. Update the existing filter to exclude routes with a minimum elevation below 1,500 metres.

Creating and populating the column

  1. Open the “Field calculator” for the snowshoe attribute table.
    1. Open the layer menu by right clicking the “schneeschuhwanderwege_2056.gpk” layer in the layer pane (bottom left)
    2. From the menu click “Open Attribute Table” to open the attribute table pane below the map.
    3. Open the “Field Calculator by clicking the “Edit” button and then the “Open field calculator” button in the toolbar above the attribute table open-attribute-table.png
  2. Create a new “min_elev” column in the data.
    1. Ensure “Create a new field” is checked.
    2. Enter “min_elev” in the “Output field name” field
    3. Enter “z_min($geometry)” in the “Expression” field
    4. Click “OK” field-calculator-min_elev.png

This has created a new column in the snow show data called “min_elev”, with a value calculated as the lowest Z point (elevation) in the route geometry for that row.

You can verify this in the attribute table. Scroll the table to the right and you will see the new min_elev column at the end.

Exclude routes below 1,500 metres

  1. Click the “Edit” button in the attribute pane to disable editing.
  2. Open the layer menu by right clicking the “schneeschuhwanderwege_2056.gpk” layer in the layer pane.
  3. Choose “Filter...” to open the Query Builder.
  4. Click the “Test” button.
    1. If you are using the query from yesterday you will see a dialog message, “The where clause returned 39 row(s)”. If you are using a different query the number of rows will be different.
    2. Click OK to dismiss the dialog.
  5. Edit the query to add a final clause, AND min_elev >= 1500. query-builder-min_elev.png
  6. Click the “Test” button again.
    1. If you where using the query from yesterday you will see a dialog message, “The where clause returned 9 row(s)”. So 30 routes that dip below 1,500 metres have been removed. If you were using a different query you will see a different number of results.
    2. Click OK to dismiss the dialog.
  7. Click OK to dismiss the Query Builder

Switzerland's great for winter activities, including snowshoeing. But finding somewhere to go can be frustrating.

Sites like SchweizMobil have a list of routes and useful information; length, total ascent/descent, route maps, and so on. But support for searching for routes that meet specific critieria is lacking.

In my case I wanted to find routes of between 5 and 12km, up to 300m of ascent, and taking no more than 4 hours. I'd like to see this on a map (so I can get a rough idea of where in the country they are), and quickly click between them as part of making a decision.

SchweizMobil doesn't support this sort of search, and you're left to scroll through routes, open them in different tabs, and eyeball some of the data to decide whether the route is suitable.

There's a better way. opendata.swiss provides all the necessary data under a range of licenses.

This data can be loaded in to QGIS, an open source desktop geographic information system (GIS) for displaying and manipulating map and related data.

Together this allows you to create your own personalised map of suitable routes.

At the end of this process you will be able to see a route on the map, including the elevation profile, like this:

elevation-pane-active-profile.png

Install and run QGIS

Install QGIS by following the instructions at the download page at the QGIS Web Site.

Launch QGIS after installation.

Install QGIS plugins

QGIS supports plugins that provide additional functionality, and we will use two:

  • QuickMapServices
  • Swiss Geo Downloader

Both of these plugins make it easy to load data from external sources directly into QGIS.

QuickMapServices displays data from OpenStreetMap directly in QGIS. We will use this to provide the map of Switzerland and routing.

Swiss Geo Downloader allows you to search for and import Swiss OpenData in QGIS.

There are other ways of accomplishing both of these tasks, the plugins make it straightforward.

To install these plugins:

  1. From the QGIS menu choose “Plugins > Manage and Install Plugins...”.
  2. Search for “QuickMapServices”, select it, and click “Install Plugin”.
  3. Search for “Swiss Geo Downloader”, select it, and click “Install Plugin”.
  4. The plugins dialog should look like this. The exact list of installed plugins may differ depending on the version of QGIS you have installed, but “QuickMapServices” and “Swiss Geo Downloader” should both be listed and checked. plugins-dialog.png
  5. Close the “Plugins” dialog.

Use OpenStreetMap as the base layer

  1. From the QGIS menu choose “Web > QuickMapServices > OSM > OSM Standard”.
  2. A world map should appear in QGIS, and the “OSM Standard” layer should appear in the “Layers” pane at the bottom left.
  3. Zoom in on Switzerland. It should look like this: switzerland-base-layer.png

Import snowshoe routes

  1. From the QGIS menu choose “Plugins > Swiss Geo Downloader > Swiss Geo Downloader”.
  2. You may be prompted to switch to the Swiss coordinate reference system, LV95. Choose “Yes”.
    1. If you are not already zoomed in to Switzerland the map will become very distorted. This is normal. The distortion will disappear when you zoom in on Switzerland
  3. A “Swiss Geo Downloader” window pane will open, with a search bar at the top.
  4. Enter “snowshoe” in the search bar.
  5. Choose the “Snowshoe trails” dataset from the results. Section 3 of the window should show “schneeschuhwanderwege_2056.gpkg” downloading-snowshoe-routes.png
  6. Click “Download”, and choose a directory when prompted.
  7. The “schneeschuhwanderwege_2056.gpkg” layer should appear in the “Layers” pane downloaded-snowshoe-routes.png

Improve visibility of the routes

The snowshoe routes may be difficult to view on the map. To improve their visibility:

  1. Double-click the “schneeschuhwanderwege_2056.gpkg” layer, to open the “Layer Properties” dialog.
  2. Adjust the colour, width, and other properties. For example, click “effect neon” and set the width to “1mm” to quickly make the routes stand out. layer-properties-symbology-neon.png
  3. The available routes should now be much more visible on the map: layer-properties-neon-applied.png

Filtering the routes

The map is currently showing all ~ 200 routes. To filter them to your own criteria:

  1. Right click the “schneeschuhwanderwege_2056.gpkg” layer and choose “Filter...” from the menu to open the “Query Builder” dialog.
  2. Use this to build a query for the routes you care about.

The query builder field names are in German, and have the following meanings:

  • NameR: String, route name
  • NrR: Number, route number (e.g., 101 is SchweizMobil route 101)
  • BeschriebR: String, route display name
  • LaengeR: Number, length in kilometres, e.g., 8.1
  • HoeheAufR: Number, ascent in metres
  • HoeheAbR: Number, descent in metres
  • ZeitStZiR: Number, estimated time in minutes, e.g., 225 = 3h45m?
  • TechnikR_en: String, difficulty level (english), e.g., “blue”

For example, the following query filters to routes that are:

  1. Between 5km and 12km
  2. Have a maximum ascent of 300m
  3. Take 4 hours (4 * 60 = 240 minutes) or less
LaengeR >= 5 AND LaengeR <= 12 
AND HoeheAufR <= 300
AND ZeitStZiR <= 240

query-builder.png

After applying the filter the map will look like this (your map will look different if you used different filter criteria).

filtered-routes.png

View an elevation profile

QGIS can display elevation profiles for routes.

To activate this feature:

  1. Choose “View > Elevation Profile” from the menu.
  2. The “Elevation Profile” pane will open at the bottom of the display. elevation-panel-after-open.png
  3. Click the small “Capture Curve from Feature” button in the “Elevation Profile” pane. elevation-panel-capture-curve-from-feature.png
  4. On the map, find the route you want to see the elevation profile for.
  5. Click the route.
  6. The elevation profile for the route will appear. elevation-pane-active-profile.png

Click a route to open a web page

The “schneeschuhwanderwege_2056.gpkg” data also contains URLs for each route. QGIS can be configured to open that URL if you click the route.

  1. Right click the “schneeschuhwanderwege_2056.gpkg” layer and choose “Properties...” from the menu to open the “Layer Properties” dialog.
  2. Choose “Actions” from the list of properties on the left of the dialog.
  3. Click the green “+” button to open the “Add New Action” dialog.
  4. Use the following action settings:
    1. Type: “Open URL”
    2. Description: “Open SchweizMobil”
    3. Action Scopes: Check “Canvas”, leave other options unchecked
    4. Action Text: [% ChmURL_en %]
      1. This will open the English page. Other options are [% ChmURL_de], [% ChmURL_fr], [% ChmURL_it], or [% ChmURL_rm] for German, French, Italian, and Romansh respectively.
  5. Click “Ok” twice to dismiss both dialogs.

You can now enable the action in the QGIS toolbar.

add-new-action-dialog.png

Suppose you have to write some documentation. It might be a tutorial, a how-to guide, or an explanation of a topic (see Diátaxis for more on those).

Challenges when doing this include:

  • Deciding how to structure the document.
  • Deciding when the document is complete.
  • Over-stuffing the document with information that is not relevant to the reader at that time.
  • Ensuring the reader has the necessary prerequisite knowledge to understand the material.

To combat those problems start the document with a “Synopsis” section that consists of:

  • 1-n paragraphs that provide a very brief introduction to the topic. That might be an overview of the topic, or perhaps a description of the specific problem the documentation will help you solve.
  • A list of 0-n bullet points that start “Before reading this document you should:“.
    • Each bullet point should list a task to complete, with a link to material that explains how the reader can complete the task. The task might be an activity they need to complete, or a document they should read before this one.
  • A list of 1-n bullet points that start “After reading this document you will know:”

I have found this to be invaluable when organising your thoughts.

  1. If you cannot write a brief introduction to the topic that is an indication you either do not understand it well enough to write about it, or the topic is too complex to cover in a single document and should be separated into smaller documents.
  2. The “... you will know” bullet points serve several functions:
    1. They're your “to-do list” of things to write about in the document. You know the document is complete / ready for editing when you have ticked off each bullet point.
    2. If you find yourself writing more than ~ seven bullet points that's a good sign the document is going to be too complex, and should be broken in to smaller documents.
  3. Similarly, the “... you should:” bullet points have several functions:
    1. They allow you to be explicit about what is not covered in the document.
    2. They clearly direct the reader to the other material.
    3. They highlight material that may be missing. If you find yourself writing an entry in this section but do not have documentation that tells the user how to complete this task you have discovered another document you need to write.

This works whether you are writing a stand-alone document or one that is part of a collection.

I first adopted this technique when I was co-editing the 2nd edition of the FreeBSD Handbook, and although that is long in the past, today's handbook continues the tradition.

The synopsis for the “Wayland” chapter is a good example. It starts:

An installation of FreeBSD using bsdinstall does not automatically install a graphical user interface. This chapter describes how to select, install, and configure a Wayland compositor, which provides a graphical environment.

Before reading this chapter, you should know:

After reading this chapter, you will know:

  • How to configure FreeBSD to host a Wayland graphical environment.
  • How to install and configure a Wayland compositor.
  • How to run programs designed for the older X Window System.
  • How to configure remote desktop access to a Wayland graphical environment.

In that example:

  • The introductory paragraph provides context for the reader.
  • The “... you should know:” bullets clearly explain prerequisite knowledge, and direct the reader to the right place to learn that knowledge.
  • The “... you will know:” bullets tightly scope the content of the document, and allow the reader to rapidly determine whether this document is going to be helpful for whatever task they are carrying out.

Inspired by a discussion in the joinmastodon.org Discord.

Someone's writing an app processing incoming HTTP requests with a Content-Type header. Specifically, they want to handle the case where the content type is application/json.

They noticed some clients are sending an unnecessary charset=utf-8 parameter in the content type, and wondered how they should properly handle this parameter.

They're doing this as a learning exercise so wanted to avoid leaning on an existing library if possible.

The discussion went back and forth a little bit between validation and parsing without really getting in to the meat of it, it's too long for a chat message post, and I'm not aware of a good discussion of the topic elsewhere, hence this post.

What to do?

The difference between validation and parsing

I use the terms to mean the following.

You validate incoming data to decide if your code should continue on the happy path, or branch to an error handler because the data is invalid. The incoming data will not be used again.

You parse incoming data to validate it, and to extract the information to a different type for use elsewhere in the program.

To use Pachli as an example, the code validates the content type of incoming data as application/json because if it's not then an error has occurred. The content type is not used after that.

Pachli parses a server's version string to a dedicated Version type to hold a semantic version because the version information is used elsewhere in the code to make decisions.

Parsing to the dedicated type once means the rest of the code knows the version is valid and can be reasoned about, instead of repeatedly having to re-parse the version string.

The Content-Type header

The Content-Type header specification is defined in RFC 9110 sect. 8.3. Using ABNF the grammar for the header is

token          = 1*tchar

tchar          = "!" / "#" / "$" / "%" / "&" / "'" / "*"
                 / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
                 / DIGIT / ALPHA
                 ; any VCHAR, except delimiters

parameters      = *( OWS ";" OWS [ parameter ] )
parameter       = parameter-name "=" parameter-value
parameter-name  = token
parameter-value = ( token / quoted-string )
  
media-type = type "/" subtype parameters
type       = token
subtype    = tokenContent-Type = media-type

Content-Type = media-type

It also notes

The type and subtype tokens are case-insensitive.

and gives these examples

text/html;charset=utf-8
Text/HTML;Charset="utf-8"
text/html; charset="utf-8"
text/html;charset=UTF-8

RFC 8259 defines the application/json media type, and is explicit that this type does not have a charset parameter, writing:

Note: No “charset” parameter is defined for this registration. Adding one really has no effect on compliant recipients.

What to do?

The original questioner can now decide whether they want to validate the header as application/json, or parse the header to a more specific set of types.

Validation

From the definition of the Content-Type header and the application/json media type we can see a sensible strategy for validation would be:

  1. Remove everything from the header value after-and-including the first occurrence of ;
  2. Trim any whitespace from the start and end of the string
  3. Perform a case-insensitive comparison of the value with application/json

Step 1 handles malformed clients or servers that emit variations of application/json;charset=utf-8 (or any other charset). While technically not correct this is unlikely to impede interoperability.

Step 2 is also for malformed clients or servers. The specification does not allow spaces before the primary type (application), and there may be spaces between the subtype and the ; that was removed in step 1. Again, removing these should be harmless and improves interoperability.

Step 3 validates the header contains the correct value.

Parsing

Parsing the header is appropriate if the application is going to branch based on the type and subtype. For example, different handlers for application/json and application/activity+json.

To do that I would create specific Python types to represent the parameters and types that can appear in a Content-Type header, and have a function that parses the header and returns one of those types.

The types should include an explicit Unknown value to indicate an unhandled type, and can either raise errors or return an additional error type.

For example:

import re
from dataclasses import dataclass

# This is an example to demonstrate parsing a value to one of a
# specific set of types, distinct from simply validating the value
# matches expectations.


@dataclass
class BaseParameter:
    """
    Base class for all parameters.

    value: Value of the parameter as it appeared in the header.
    """
    value: str


@dataclass
class Charset(BaseParameter):
    """A 'charset=...' parameter."""
    pass


@dataclass
class UnknownParameter(BaseParameter):
    """
    An unknown parameter.
    
    name: Parsed name of the parameter
    value: Parsed value of the parameter
    original: Original parameter string as it appeared in the header.
    """
    name: str
    original: str


Parameter = Charset | UnknownParameter
"""Parameters that can occur in a content-type header."""


@dataclass
class ApplicationJson:
    """The application/json content type."""
    pass


@dataclass
class ApplicationActivityPlusJson:
    """
    The application/activity+json content type

    charset: Parsed value of the 'charset' parameter, if present.
    """
    charset: Charset | None


@dataclass
class UnknownContentType:
    """
    An unknown content type.

    type: Parsed name of the type
    parameters: Parsed list of parameters, if present.
    original: Original content type as it appeared in the header.
    """
    type: str
    parameters: list[Parameter]
    original: str


ContentType = ApplicationJson | ApplicationActivityPlusJson | UnknownContentType
"""Possible content type values."""


# THIS IS NOT A PRODUCTION QUALITY PARSER
def parse_content_type(val: str) -> ContentType:
    r = re.compile('^(?P<type>[^;]+)(?:;(?P<parameters>.*))?')
    m = r.search(val)
    if m is None:
        raise ValueError
    type = m.group('type')
    if type is not None:
        type = type.lower().strip()
    match type:
        case 'application/json':
            return ApplicationJson()
        case 'application/activity+json':
            parameters = parse_parameters(m.group('parameters'))
            charset = ([e for e in parameters if isinstance(e, Charset)][:1]
                       or [None])[0]
            return ApplicationActivityPlusJson(charset)
        case _:
            parameters = parse_parameters(m.group('parameters'))
            retur