Tildes Docs

Development

Page last updated: August 13, 2018 (view history)
Contents

Note: This page is fairly scattered overall and should be split up into multiple pages in the future. The goal is mostly to have a lot of information that developers might find useful so that they at least have a starting point to find out more about how something works.

If you haven't already, follow the instructions on the Development Setup page to get a development version running on your local machine.

Vagrant

In general you shouldn't need to do very much with Vagrant directly, but there are a few commands you should know:

Changing the Vagrant box's resources

This generally shouldn't be necessary, but it's possible to give the Vagrant box access to more resources by creating a file named Customfile in the same directory as the Vagrantfile. For example, to give it 4GB of memory and 2 CPUs, the file should contain:

config.vm.provider "virtualbox" do |vb|
    vb.memory = "4096"
    vb.cpus = "2"
end

SSHing into the VM

You can open an SSH session into the VM by running vagrant ssh. Once inside, the application's virtual environment (venv) should automatically be activated for you, and you should be in the base application directory (/opt/tildes/).

Unless otherwise stated, all commands mentioned in this page should be assumed as needing to be run from inside the VM, from the directory /opt/tildes/, with the venv activated.

Shared folders with the VM

Vagrant maintains a few shared folders with the VM, which means that any changes you make to files in these folders from outside will be available inside the VM immediately. This allows you to edit the code from your main OS and have those changes reflected by your dev site that is being served from the VM. Warning: This sharing is bi-directional, so any changes made in these folders inside the VM will also affect the folders outside. Take care not to delete or change files in the shared folders inside the VM unless you want those changes to affect the "real" version of the file outside.

The following folders are shared (defined in the Vagrantfile):

Outside folder Path inside VM
<base>/tildes/ /opt/tildes/
<base>/salt/salt/ /srv/salt/
<base>/salt/pillar/ /srv/pillar/

Keeping your dev version up-to-date

First of all, it's definitely easiest if you keep your master branch "clean" by always working on all changes in their own branch. Assuming you're working that way and have the official repo set up as a remote named "upstream" (similar to the setup in this guide), you can update to the latest code by running (from your own machine, NOT the Vagrant VM):

git checkout master
git pull upstream master

After the pull completes, you may need to run additional commands depending on what exactly was updated. These commands are safe, so it should always be fine to just run them anyway if you're not sure:

As always, if something goes wrong and your dev VM ends up in a broken state, you can just destroy and recreate it and everything should be functional again. All your code changes, branches, etc. will be safe, as long you didn't make any changes directly inside the VM (which you usually shouldn't be doing anyway).

You should also try to update your branch(es) to use the latest code as soon as possible (and definitely before submitting a merge request), which should usually be as simple as, for each branch (again, on your own machine):

git checkout my-branch-name
git rebase master

Overall language/framework/server

The application is written in Python 3.6, using the Pyramid web framework.

It is served by Gunicorn behind an nginx reverse-proxy.

General development/debugging

The development version is set up to automatically restart when any changes are detected to the code, so it should not be necessary to do anything manually to have changes take effect.

It is running in debug mode, which will mean that any Python errors encountered should be displayed in the browser including a full traceback, ability to open an interactive shell in-browser and inspect variables, and so on. The Pyramid debug toolbar is enabled and accessible on any page by clicking the red Pyramid icon on the right edge. The debug toolbar interface allows you to see various information about each request, such as examining properties of the request, and checking which database queries were executed (and how long they each took).

If you introduce a "lower-level" error such as a Python syntax error, the application will fail when it attempts to restart and you won't be able to access the usual debugging methods. This usually appears as a "502 Bad Gateway" error in the browser. To see the details of the error preventing Gunicorn from starting, you will need to run sudo journalctl -u gunicorn.service -f, which should display the traceback. Gunicorn is set up to automatically try restarting every 30 seconds, so after fixing the error you shouldn't need to do anything except wait (up to) 30 seconds for it to restart. However, you can always restart it immediately by running sudo systemctl restart gunicorn.socket.

Interactive shell

You can open an interactive IPython shell inside the app environment by running pshell development.ini. This will include a request object that you can use to make database changes via request.db_session, just as inside app code. Note that any database changes will need to be manually committed by calling request.tm.commit().

HTML templates and CSS

HTML templates are written in the Jinja2 templating language.

CSS is written with Sass (SCSS syntax), and uses Spectre.css as a base. Changes to the SCSS files (in the tildes/scss/ directory) will automatically be detected and compiled for you by a service running inside the Vagrant VM. If your changes are failing to compile due to an error, you can run sudo journalctl -u boussole.service -f to view that service's logs and see the error.

The SCSS is organized in a manner similar to the SMACSS style. One specific thing to note is that the _themes.scss file contains style definitions that differ between the different site themes. All theme-specific CSS should be kept exclusively in that file.

Javascript, AJAX and the "web API"

Javascript usage is being kept minimal. Intercooler is being used for all AJAX, which allows defining AJAX actions easily using HTML data attributes (note that it is configured so that a data- prefix is required on attributes, so the attributes will be similar to data-ic-post-to instead of just ic-post-to).

When a new endpoint is needed for Intercooler, it should be grouped in the "web API", which are all located inside /api/web/. These endpoints are intended solely for "internal" usage by the site and can be changed as needed for any functionality. For Intercooler's purposes, they should generally return HTML fragments that are intended to be swapped into the page, but can also do things like use Intercooler response headers to trigger actions.

Intercooler includes jQuery 3 as a dependency, so jQuery is available as well.

All other Javascript behavior should be implemented using the RSJS guidelines, which generally means that the code for each behavior is defined inside a file in static/js/behaviors/ and must be attached to a "component" on the site by using an HTML data attribute with the data-js- prefix.

All the files in behaviors are automatically merged into the site's javascript file. However, if you add a new behavior file, it won't be picked up until you run sudo systemctl restart webassets.service.

Database

The primary database being used is PostgreSQL 10.

You can access the command-line interface to the database by running psql -U tildes tildes. This can be useful to check exactly how things are stored or make sure that changes are applying correctly, but modifying data directly may cause inconsistencies that are usually managed by the app code, so it's often not the right approach.

Interacting with the database in the app

Database interactions inside the app are through the SQLAlchemy library. These interactions should almost always go through the per-request SQLAlchemy session available as request.db_session.

Each request is wrapped in a transaction by default, and it is not usually necessary to commit or rollback changes manually. The pyramid_tm library automatically handles most transaction tasks, including committing the transaction at the end of each request (or rolling it back if an error occurred).

If you really do need to manually control the transaction, you can call functions on the transaction manager directly such as request.tm.commit(). If you need to execute a rollback, it is almost always correct to use a savepoint and roll back to it, instead of rolling back the entire transaction (which could unexpectedly interfere with other database operations).

Making changes to the database tables

If you need to make any changes to the tables or columns (including adding new ones), you must create a revision using the Alembic migration tool which will allow all other instances of the site to easily apply the same changes to their database.

The general process for creating an Alembic migration is:

  1. Make the changes to the table definitions inside the app code (not by altering the database directly).
  2. If you are adding a new table, edit alembic/env.py and import the new model class along with the others already included there. Skip this step if you're only modifying existing tables.
  3. Run alembic revision --autogenerate -m "Short description of the changes". You will see some output including the name of a new file that was generated inside alembic/versions/.
  4. Open that new file inside your editor and review/edit all the code inside the upgrade() and downgrade() functions. Ensure that the code inside upgrade() includes all steps necessary to make the changes you need. Alembic isn't able to detect everything, so there may be some omissions. Similarly, ensure that downgrade() contains all steps to reverse those changes. Delete the automatic comments inside both these functions.

    Note: the Alembic version file should never import any constants, read text in from files, etc. It should have all that sort of information hardcoded so that the effects of the migration aren't modified if those external resources change in the future.

  5. Run alembic upgrade head to apply your upgrade and make sure there are no errors. If the upgrade applies successfully, you should also connect to the database with psql -U tildes tildes and verify that all your changes were applied correctly.

  6. If the upgrade looks correct, run alembic downgrade -1 to apply your downgrade and make sure there are no errors. As above, you should connect to the database to verify that all your changes were removed and the database is back to the state it was previously.
  7. If you run into any errors or missing changes during upgrade/downgrade, you may need to edit your script more and test more downgrades/upgrades until everything is correct. Once you're finished fixing any issues, run one final alembic upgrade head to make sure everything has been applied.
  8. Make sure to include your Alembic file inside the commit that requires these database changes.

Data validation/sanitization

Most data validation and sanitization is being performed by Marshmallow schemas, which are located inside the schemas/ directory. In general, any data that's intended to go into the database should be first filtered through a schema to ensure it fits the restrictions or intended format. This should mostly be handled automatically, since SQLAlchemy is set up to do automatic validation using the associated schema for a model.

These Marshmallow schemas are also used by the webargs library, which takes incoming data from various sources in HTTP requests (POST/form data, url, query string, etc.) and passes it through the schemas. This allows validation/sanitization and some other niceties like automatic 422 responses when the data is invalid.

Instrumentation and metrics

Prometheus is being used for application and system metrics. Your development version's metrics are accessible and queryable at http://localhost:9090.

You can add new metrics in the application using the Prometheus Python client, but make sure that you understand the different metric types available and use an appropriate one, and try to include labels so that the metrics can be filtered in useful ways if that's applicable.

Running checks on your code

Once you start making changes to the code that you want to contribute to the project, you should ensure that the following commands all do not return errors (any merge requests that fail these checks will not be accepted).

If you use the included Git hooks, the tests and mypy will automatically be run whenever you commit (and prevent you from committing if they fail), and all three will be run when you push your code to a remote repo.

Testing

Tests are written using the pytest framework and are located inside the tests/ directory. pytest will only detect new tests if they are inside a file whose name starts with test_, and with a function name also starting with test_ (other files/functions can be used for setup, fixtures, and so on).

Tests are able to (and should) use all database functions that the app itself can use. They operate inside their own PostgreSQL database, which uses nested transactions and transactional DDL to create tables that exist only inside the overall testing session's transaction (which is never committed). This ensures that no database changes persist between sessions even if the tests crash, and each testing session is always working with a clean database.

Tests also have access to their own temporary Redis instance that can be used when testing all functions involving Redis, such as rate-limiting.

The pytest-mock library is available for mocking/patching, as well as the freezegun library for tests that need control of time/date.

Functional testing can be done via the webtest library, which allows "higher-level" testing by sending requests to the app's URLs, examining responses, and so on. All tests using webtest should be kept inside the webtests/ subdirectory, and can be excluded from a test run by running pytest -k "not webtests".

Type-checking with mypy

All function definitions must include type annotations that define the types of each argument and the return value. These annotations have no effect at runtime, but mypy is able to statically analyze the code using them to detect potential errors when functions are inadvertently called with arguments of the wrong type.

The type annotations are generally very straightforward and you can probably easily understand how to write them by just looking at existing functions, but the following pages in the mypy docs might be useful if you run into issues or need more information: