PortalAuth, Papers, and CursedScreech Updates (#87)

* Version 1.9

* Version 2.0

* Version 1.6

* Updated Papers to v2.0

* Replaced readKeys.sh with cfgNginx.py

* Fixed PKCS12 export bug

Co-authored-by: combsn <combsn@usc.edu>
master
Nick 2020-07-19 16:24:52 -04:00 committed by GitHub
parent f1ca07b311
commit aa43cb5e23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 4244 additions and 182 deletions

View File

@ -313,7 +313,7 @@ class CursedScreech extends Module {
$data = array();
exec("pgrep -lf " . $procName, $data);
$output = explode(" ", $data[0]);
if (strpos($output[2], $procName) !== False) {
if (strpos($output[1], "python") !== False) {
return $output[0];
}
return false;
@ -651,4 +651,4 @@ class CursedScreech extends Module {
return true;
}
}
}
}

View File

@ -0,0 +1,4 @@
December 26, 2019
<br /><br />
- Fixed bug in latest firmware that saved module settings in an invalid state causing issues when running Sein.<br />

View File

@ -1,8 +1,8 @@
kuro_key=
target_key=
client_serial=File does not exist
iface_name=br-lan
iface_ip=172.16.42.1
iface_name=wlan2
iface_ip=192.168.0.138
mcast_group=231.253.78.29
mcast_port=19578
target_list=/pineapple/modules/CursedScreech/includes/forest/targets.log

View File

@ -6,4 +6,4 @@ if [ $# -lt 1 ]; then
fi
ifconfig $1 | grep inet | awk '{split($2,a,":"); print a[2]}'
ifconfig $1 | grep inet | awk '{split($2,a,":"); print a[2]}' | tr -d '\n'

View File

@ -6,5 +6,5 @@
"tetra"
],
"title": "CursedScreech",
"version": "1.5"
"version": "1.6"
}

1
Papers/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vscode

4
Papers/README.md Normal file
View File

@ -0,0 +1,4 @@
# Papers
This is a module for the WiFi Pineaple NANO and TETRA that allows you to create and manage TLS/SSL certificates with ease. It will also update the nginx configuration on the Pineapple to use/remove SSL automatically.
Check out the [tutorial video](https://www.youtube.com/watch?v=XQ6qmouMtS4) for Papers.

View File

@ -83,7 +83,10 @@ class Papers extends Module
break;
case 'loadCertProps':
$this->loadCertificateProperties($this->request->certName);
break;
break;
case 'loadSSHKeys':
$this->loadSSHKeys($this->request->keyName);
break;
case 'downloadKeys':
$this->downloadKeys($this->request->parameters->name, $this->request->parameters->type);
break;
@ -255,28 +258,19 @@ class Papers extends Module
$cryptInfo = array();
$argString = "";
$cryptInfo['-k'] = $keyName;
$cryptInfo['-k'] = "{$keyName}.key";
$cryptInfo['-a'] = $params['algo'];
// Check if the certificate should be encrypted
if (array_key_exists('encrypt', $params)) {
$argString = "--encrypt ";
$cryptInfo['-a'] = (array_key_exists('algo', $params)) ? $params['algo'] : False;
$cryptInfo['-p'] = (array_key_exists('pkey_pass', $params)) ? $params['pkey_pass'] : False;
if (!$cryptInfo['-a'] || !$cryptInfo['-p']) {
$this->logError("Build Certificate Error", "The public and private keys were generated successfully but an algorithm or password were not supplied for encryption. The certs can still be found in your SSL store.");
$this->respond(false, "Build finished with errors. Check the logs for details.");
return;
}
}
// Check if the certificates should be placed into an encrypted container
if (array_key_exists('container', $params)) {
$cryptInfo['--pubkey'] = "{$keyName}.cer";
$cryptInfo['-c'] = (array_key_exists('container', $params)) ? $params['container'] : False;
$cryptInfo['-calgo'] = (array_key_exists('c_algo', $params)) ? $params['c_algo'] : False;
$cryptInfo['-cpass'] = (array_key_exists('c_pass', $params)) ? $params['c_pass'] : False;
}
// Build an argument string with all available arguments
foreach ($cryptInfo as $k => $v) {
if (!$v) {continue;}
@ -284,11 +278,11 @@ class Papers extends Module
}
$argString = rtrim($argString);
// Execute encryptKeys.sh with the parameters and check for errors
// Execute encryptRSAKeys.sh with the parameters and check for errors
$retData = array();
exec(__SCRIPTS__ . "encryptKeys.sh " . $argString, $retData);
$res = implode("\n", $retData);
if ($res != "Complete") {
exec("echo " . escapeshellcmd($params['pkey_pass']) . " | " . __SCRIPTS__ . "encryptRSAKeys.sh {$argString}", $retData);
if (end($retData) != "Complete") {
$res = implode("\n", $retData);
$this->logError("Certificate Encryption Error", "The public and private keys were generated successfully but encryption failed with the following error:\n\n" . $res);
$this->respond(false, "Build finished with errors. Check the logs for details.");
return;
@ -298,16 +292,16 @@ class Papers extends Module
}
private function encryptKey($keyName, $keyType, $algo, $pass) {
$retData = array();
$argString = "encryptKeys.sh --encrypt -k " . $keyName . " -a " . $algo . " -p " . $pass;
$retData = array();
$cmdString = "encryptRSAKeys.sh --encrypt -k {$keyName}.key -a {$algo}";
if ($keyType == "SSH") {
$argString .= " --ssh";
}
$cmdString = "encryptSSHKey.sh -k {$keyName}.key";
}
exec(__SCRIPTS__ . $argString, $retData);
$res = implode("\n", $retData);
if ($res != "Complete") {
exec("echo " . escapeshellcmd($pass) . " | " . __SCRIPTS__ . $cmdString, $retData);
if (end($retData) != "Complete") {
$res = implode("\n", $retData);
$this->logError("Key Encryption Error", "The following error occurred:\n\n" . $res);
return false;
}
@ -315,16 +309,16 @@ class Papers extends Module
}
private function decryptKey($keyName, $keyType, $pass) {
$retData = array();
$argString = "decryptKeys.sh -k " . $keyName . " -p " . $pass;
$retData = array();
$cmdString = "decryptRSAKeys.sh -k {$keyName}.key";
if ($keyType == "SSH") {
$cmdString = "decryptSSHKey.sh -k {$keyName}.key";
}
if ($keyType == "SSH") {
$argString .= " --ssh";
}
exec(__SCRIPTS__ . $argString, $retData);
$res = implode("\n", $retData);
if ($res != "Complete") {
exec("echo " . escapeshellcmd($pass) . " | " . __SCRIPTS__ . $cmdString, $retData);
if (end($retData) != "Complete") {
$res = implode("\n", $retData);
$this->logError("Key Decryption Error", "The following error occurred:\n\n" . $res);
return false;
}
@ -374,7 +368,7 @@ class Papers extends Module
$retData = array();
$res = [];
exec(__SCRIPTS__ . "getCertInfo.sh -k " . $cert, $retData);
exec(__SCRIPTS__ . "getCertInfo.sh -k {$cert}.cer", $retData);
if (count($retData) == 0) {
$this->respond(false);
return false;
@ -386,12 +380,23 @@ class Papers extends Module
$key = $parts[0];
$val = $parts[1];
$res[$key] = $val;
}
}
$res['privkey'] = file_get_contents(__SSLSTORE__ . "{$cert}.key");
$res['certificate'] = file_get_contents(__SSLSTORE__ . "{$cert}.cer");
// Return success and the contents of the tmp file
$this->respond(true, null, $res);
return true;
}
}
private function loadSSHKeys($name) {
$this->respond(true, null, array(
"privkey" => file_get_contents(__SSHSTORE__ . "{$name}.key"),
"pubkey" => file_get_contents(__SSHSTORE__ . "{$name}.pub"))
);
return true;
}
private function getKeys($dir) {
$keyType = ($dir == __SSLSTORE__) ? "TLS/SSL" : "SSH";
@ -436,12 +441,13 @@ class Papers extends Module
private function keyIsEncrypted($keyName, $keyType) {
$data = array();
$keyDir = ($keyType == "SSH") ? __SSHSTORE__ : __SSLSTORE__;
exec(__SCRIPTS__ . "testEncrypt.sh -k " . $keyName . " -d " . $keyDir . " 2>&1", $data);
if ($data[0] == "writing RSA key") {
return false;
} else if ($data[0] == "unable to load Private Key") {
$keyDir = ($keyType == "SSH") ? __SSHSTORE__ : __SSLSTORE__;
$type = ($keyType == "SSH") ? "SSH" : "RSA";
exec(__SCRIPTS__ . "isEncrypted.sh -k {$keyName}.key -d {$keyDir} -t {$type} 2>&1", $data);
if ($data[0] == "true") {
return true;
} else if ($data[0] == "false") {
return false;
}
}
@ -536,7 +542,7 @@ class Papers extends Module
private function checkSSLConfig() {
$retData = array();
exec(__SCRIPTS__ . "readKeys.sh", $retData);
exec(__SCRIPTS__ . "cfgNginx.py --getSSLCerts", $retData);
return implode(" ", $retData);
}

View File

@ -0,0 +1,6 @@
December 26, 2019<br /></br >
- Fixed bug in latest firmware that prevented the module from detecting whether a key is encrypted.<br />
- Fixed bug in latest firmware that broke the nginx config when removing SSL certs.<br />
- Added requirement to remove SSL certs before uninstalling dependencies due to a change made by adde88 that swaps nginx/nginx-ssl as dependencies in the latest firmware.<br />
- Added requirement to install dependencies (nginx-ssl) before gaining the ability to install SSL certificates.<br />
- Updated the Status section help file.<br />

View File

@ -0,0 +1,5 @@
July 17, 2020<br /></br >
- Fixed SSH key encryption to work with new OpenSSH format.<br />
- Added raw key contents to key view modal for easy copy/paste.<br />
- Merged encryption passphrase section with PKCS#12 export. The same password is used for both operations.<br />
- Added coreutils-base64 dependency to help determine if an SSH key is encrypted.

View File

@ -3,8 +3,11 @@ Displays the keys currently being used by nginx on your Pineapple. These values
<br /><br />
<strong>Dependencies</strong><br />
The only dependencies for Papers are zip and unzip which are downloaded via opkg. They are used to pack, and unpack, certificate archives for download/upload.
<br /><br />
zip and unzip - used to pack and unpack certificate archives for download/upload.<br />
coreutils-base64 - Used in the process to determine whether an SSH key is encrypted.<br />
nginx-ssl - Replaces the base nginx application.<br />
<p style="color: red">Since nginx is replaced with nginx-ssl in the installation process you may need to refresh your browser after installation if the button doesn't change to 'Uninstall'</p>
<p style="color: red">In order to uninstall dependencies you must first remove the SSL certificates from your Pineapple.</p>
<strong>Remove SSL</strong><br />
This reverts the Pineapple back to its original web configuration and removes all traces of SSL from the nginx config. This does not affect the certificate store or any configuration in the Papers module.

View File

@ -1,13 +1,10 @@
user root root;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
index index.php index.html index.htm;
@ -24,8 +21,8 @@ http {
gzip_types text/plain application/x-javascript text/css application/xml;
gzip_vary on;
server {
listen 80; # Port, make sure it is not in conflict with another http daemon.
server_name www; # Change this, reference -> http://nginx.org/en/docs/http/server_names.html
listen 80; # Port, make sure it is not in conflict with another http daemon.
server_name www; # Change this, reference -> http://nginx.org/en/docs/http/server_names.html
error_page 404 =200 /index.php;
error_log /dev/null;
access_log /dev/null;
@ -51,16 +48,15 @@ http {
if (-f $request_filename) {
# Only throw it at PHP-FPM if the file exists (prevents some PHP exploits)
fastcgi_pass unix:/var/run/php5-fpm.sock; # The upstream determined above
fastcgi_pass unix:/var/run/php7-fpm.sock; # The upstream determined above
}
}
error_page 404 =200 /index.php;
}
server {
listen 1471;
server_name pineapple; # Change this, reference -> http://nginx.org/en/docs/http/server_names.html
listen 1471; # Port, make sure it is not in conflict with another http daemon.
server_name pineapple; # Change this, reference -> http://nginx.org/en/docs/http/server_names.html
error_page 404 =200 /index.php;
error_log /dev/null;
access_log /dev/null;
@ -87,9 +83,8 @@ http {
if (-f $request_filename) {
# Only throw it at PHP-FPM if the file exists (prevents some PHP exploits)
fastcgi_pass unix:/var/run/php5-fpm.sock; # The upstream determined above
fastcgi_pass unix:/var/run/php7-fpm.sock; # The upstream determined above
}
}
}
}

View File

@ -2,14 +2,19 @@
testZip=$(opkg list-installed | grep -w 'zip')
testUnzip=$(opkg list-installed | grep -w 'unzip')
testBase64=$(opkg list-installed | grep -w 'coreutils-base64')
testNginxssl=$(opkg list-installed | grep -w 'nginx-ssl')
if [ -z "$testZip" -a -z "$testNginxssl" ]; then
echo "Not Installed";
if [ -z "$testBase64" ]; then
echo "Not Installed";
else
if [ -z "$testUnzip" ]; then
echo "Not Installed";
else
echo "Installed";
fi
if [ -z "$testZip" -a -z "$testNginxssl" ]; then
echo "Not Installed";
else
if [ -z "$testUnzip" ]; then
echo "Not Installed";
else
echo "Installed";
fi
fi
fi

View File

@ -0,0 +1,73 @@
#!/bin/sh
# Author: sud0nick
# Date: Dec 2018
# Location of SSL keys
SSL_STORE="/pineapple/modules/Papers/includes/ssl/";
help() {
echo "Decryption script for OpenSSL keys";
echo "Usage: ./decryptRSAKeys.sh <opts>";
echo "Use './decryptRSAKeys.sh --examples' to see example commands";
echo '';
echo 'NOTE:';
echo "Current SSL store is at $SSL_STORE";
echo '';
echo 'Parameters:';
echo '';
echo -e '\t-k:\tFile name of key to be decrypted';
echo -e '\t-p:\tPassword to use to unlock the key';
echo -e '\t-s:\tKey store to use other than default.'
echo -e '\t--help:\tDisplays this help info';
echo '';
}
examples() {
echo '';
echo 'Examples:';
echo 'Decrypt private key:';
echo './decryptRSAKeys.sh -k keyName -p password';
echo '';
echo '';
}
if [ "$#" -lt 1 ]; then
help;
exit;
fi
KEYDIR=$SSL_STORE
read PASS
while [ "$#" -gt 0 ]; do
if [[ "$1" == "--examples" ]]; then
examples;
exit;
fi
if [[ "$1" == "--help" ]]; then
help;
exit;
fi
if [[ "$1" == "-k" ]]; then
KEY="$2";
fi
if [[ "$1" == "-p" ]]; then
PASS="$2";
fi
if [[ "$1" == "-s" ]]; then
KEYDIR="$2"
fi
shift
done;
# Generate a password on the private key
openssl rsa -in $KEYDIR/$KEY -out $KEYDIR/$KEY -passin pass:"$PASS" 2>&1 > /dev/null;
if [[ $? != 0 ]]; then
echo "Bad Password";
exit;
fi
echo "Complete"

View File

@ -0,0 +1,55 @@
#!/bin/sh
# Author: sud0nick
# Date: July 2020
# Location of SSH keys
SSH_STORE="/pineapple/modules/Papers/includes/ssh/";
help() {
echo "Encrypt OpenSSH private keys";
echo "Usage: ./encryptSSHKey.sh <opts>";
echo '';
echo 'NOTE:';
echo "Current SSH store is at $SSH_STORE";
echo '';
echo 'Parameters:';
echo '';
echo -e '\t-k:\tFile name of key to be encrypted';
echo '';
echo 'Options:';
echo '';
echo -e "\t-s:\t\tUse an SSH store other than the default."
echo '';
}
if [ "$#" -lt 1 ]; then
help;
exit;
fi
# Read password from pipe input
read PASS
# Fetch arguments from command line
while [ "$#" -gt 0 ]; do
if [[ "$1" == "-k" ]]; then
KEY="$2";
fi
if [[ "$1" == "-s" ]]; then
SSH_STORE="$2";
fi
shift
done;
# Decrypt the key
ssh-keygen -o -p -P "$PASS" -N "" -q -f $SSH_STORE/$KEY 2>&1 > /dev/null
if [[ "$?" == "0" ]]; then
echo "Complete"
else
echo "false"
fi

View File

@ -0,0 +1,97 @@
#!/bin/sh
# Author: sud0nick
# Date: Jan 2016
# Location of SSL keys
SSL_STORE="/pineapple/modules/Papers/includes/ssl/";
help() {
echo "Encryption/Export script for OpenSSL certificates";
echo "Usage: ./encryptRSAKeys.sh <opts>";
echo "Use './encryptRSAKeys.sh --examples' to see example commands";
echo '';
echo 'NOTE:';
echo "Current SSL store is at $SSL_STORE";
echo '';
echo 'Parameters:';
echo '';
echo -e '\t-k:\tFile name of key to be encrypted';
echo '';
echo 'Encryption Options:';
echo '';
echo -e '\t--encrypt:\tMust be supplied to encrypt keys';
echo -e '\t-a:\t\tAlgorithm to use for key encryption (aes256, 3des, camellia256, etc)';
echo '';
echo 'Container Options:';
echo '';
echo -e '\t-c:\t\tContainer type (pkcs12, pkcs8)';
echo -e '\t--pubkey:\tFile name of public key. Must be in selected key store.';
echo '';
}
examples() {
echo '';
echo 'Examples:';
echo 'Encrypt private key:';
echo 'echo $pass | ./encryptRSAKeys.sh -k keyName.key --encrypt -a aes256';
echo '';
echo 'Export keys to PKCS#12 container:';
echo 'echo $pass | ./encryptRSAKeys.sh -k keyName.key -c pkcs12 -a aes256';
echo '';
echo 'Encrypt private key and export to PKCS#12 container using same algo and pass:';
echo './encryptRSAKeys.sh -k keyName.key --encrypt -a aes256 -c pkcs12';
echo '';
}
if [ "$#" -lt 1 ]; then
help;
exit;
fi
ENCRYPT_KEYS=false;
KEYDIR=$SSL_STORE;
read PASS
while [ "$#" -gt 0 ]; do
if [[ "$1" == "--examples" ]]; then
examples;
exit;
fi
if [[ "$1" == "--encrypt" ]]; then
ENCRYPT_KEYS=true;
fi
if [[ "$1" == "-a" ]]; then
ALGO="$2";
fi
if [[ "$1" == "-k" ]]; then
KEY="$2";
fi
if [[ "$1" == "-c" ]]; then
CONTAINER="$2";
fi
if [[ "$1" == "-s" ]]; then
KEYDIR="$2"
fi
if [[ "$1" == "--pubkey" ]]; then
PUBKEY="$2"
fi
shift
done;
# Generate a password on the private key
if [ $ENCRYPT_KEYS = true ]; then
openssl rsa -$ALGO -in $KEYDIR/$KEY -out $KEYDIR/$KEY -passout pass:"$PASS" 2>&1 > /dev/null;
fi
# If a container type is present but not an algo or pass then use
# the same algo and pass from the private key
if [ -n "$CONTAINER" ]; then
# Generate a container for the public and private keys
openssl $CONTAINER -$ALGO -export -nodes -out $KEYDIR/${KEY%%.*}.pfx -inkey $KEYDIR/$KEY -in $KEYDIR/$PUBKEY -passin pass:"$PASS" -passout pass:"$PASS" 2>&1 > /dev/null;
fi
echo "Complete"

View File

@ -0,0 +1,55 @@
#!/bin/sh
# Author: sud0nick
# Date: July 2020
# Location of SSH keys
SSH_STORE="/pineapple/modules/Papers/includes/ssh/";
help() {
echo "Encrypt OpenSSH private keys";
echo "Usage: ./encryptSSHKey.sh <opts>";
echo '';
echo 'NOTE:';
echo "Current SSH store is at $SSH_STORE";
echo '';
echo 'Parameters:';
echo '';
echo -e '\t-k:\tFile name of key to be encrypted';
echo '';
echo 'Options:';
echo '';
echo -e "\t-s:\t\tUse an SSH store other than the default."
echo '';
}
if [ "$#" -lt 1 ]; then
help;
exit;
fi
# Read password from pipe input
read PASS
# Fetch arguments from command line
while [ "$#" -gt 0 ]; do
if [[ "$1" == "-k" ]]; then
KEY="$2";
fi
if [[ "$1" == "-s" ]]; then
SSH_STORE="$2";
fi
shift
done;
# Encrypt the key
ssh-keygen -o -p -N "$PASS" -q -f $SSH_STORE/$KEY 2>&1 > /dev/null
if [[ "$?" == "0" ]]; then
echo "Complete"
else
echo "false"
fi

View File

@ -4,14 +4,14 @@
# Date: Dec 2018
# Location of SSL keys
ssl_store="/pineapple/modules/Papers/includes/ssl/";
SSL_STORE="/pineapple/modules/Papers/includes/ssl/";
help() {
echo "Get certificate properties via OpenSSL";
echo "Usage: ./getCertInfo.sh <opts>";
echo '';
echo 'NOTE:';
echo "Current SSL store is at $ssl_store";
echo "Current SSL store is at $SSL_STORE";
echo '';
echo 'Parameters:';
echo '';
@ -28,7 +28,7 @@ while [ "$#" -gt 0 ]
do
if [[ "$1" == "-k" ]]; then
KEY="$ssl_store$2.cer";
KEY="$SSL_STORE/$2";
fi
shift

View File

@ -1,11 +1,9 @@
#!/bin/sh
# Author: sud0nick & adde88
# Date: 18.10.2019
# Date: July 17, 2020
opkg update > /dev/null;
/etc/init.d/nginx stop > /dev/null;
opkg remove nginx > /dev/null;
opkg install zip unzip nginx-ssl > /dev/null;
/etc/init.d/nginx restart > /dev/null;
opkg install zip unzip coreutils-base64 nginx-ssl > /dev/null;
echo "Complete"

View File

@ -0,0 +1,89 @@
#!/bin/sh
SSL_STORE="/pineapple/modules/Papers/includes/ssl/";
SSH_STORE="/pineapple/modules/Papers/includes/ssh/";
help() {
echo "Usage: ./testEncrypt.sh <opts>";
echo '';
echo 'NOTE:';
echo "Current SSL store is at $SSL_STORE";
echo "Current SSH store is at $SSH_STORE";
echo '';
echo 'Parameters:';
echo '';
echo -e '\t-k:\tName of key to test.';
echo -e '\t-t:\tType of key: RSA|SSH.';
echo -e "\t-s:\tKey store to use other than default."
echo '';
}
if [ "$#" -lt 2 ]; then
help;
exit;
fi
KEYDIR=''
# Get arguments
while [ "$#" -gt 0 ]; do
if [[ "$1" == "-k" ]]; then
KEY="$2"
fi
if [[ "$1" == "-s" ]]; then
KEYDIR="$2"
fi
if [[ "$1" == "-t" ]]; then
TYPE="$2"
fi
shift
done;
# If the type selected is SSH...
if [[ "$TYPE" == "SSH" ]]; then
if [[ "$KEYDIR" == "" ]]; then
KEYDIR=$SSH_STORE
fi
# Pull the header from the key file
HEADER=$(sed '1d;$d' $KEYDIR/$KEY | base64 -d | head -c 32)
FORMAT=$(echo $HEADER | cut -c 0-14)
ENC=$(echo $HEADER | cut -c 16-19)
# Ensure the key is in OpenSSH private key format
if [[ "$FORMAT" == "openssh-key-v1" ]]; then
# Check if the key is encrypted
if [[ "$ENC" == "none" ]]; then
echo "false"
else
echo "true"
fi
else
# This should never happen...
echo "Invalid OpenSSH key"
fi
else
if [[ "$TYPE" == "RSA" ]]; then
if [[ "$KEYDIR" == "" ]]; then
KEYDIR=$SSL_STORE
fi
# Check if the RSA key is encrypted
RES=$(openssl rsa -in $KEYDIR/$KEY -passin pass:_ 2>&1 > /dev/null)
if [[ "$?" == "1" ]]; then
echo "true"
else
echo "false"
fi
else
# This should never happen when called from the module.
echo "Invalid option: $TYPE"
fi
fi

View File

@ -1,18 +0,0 @@
#!/bin/bash
# Author: sud0nick
# Date: April 6, 2016
IN_SERVER_BLOCK=false;
while read p; do
if [[ $IN_SERVER_BLOCK == false ]]; then
if [[ $p == *"listen"* && $p == *"1471"* ]]; then
IN_SERVER_BLOCK=true;
fi
else
if [[ $p == *".cer;" || $p == *".key;" ]]; then
echo $p | cut -d '/' -f 5 | tr -d ';';
fi
fi
done < /etc/nginx/nginx.conf

View File

@ -1,8 +1,8 @@
#!/bin/sh
# Author: sud0nick & adde88
# Date: 18.10.2019
# Date: July 17, 2020
/etc/init.d/nginx stop > /dev/null;
opkg remove zip unzip nginx-ssl > /dev/null;
opkg update > /dev/null;
opkg remove zip unzip coreutils-base64 nginx-ssl > /dev/null;
opkg install nginx > /dev/null;

View File

@ -32,4 +32,4 @@ fi
shift
done;
openssl rsa -in $KEYDIR$KEY -passin pass: | awk 'NR==0;'
openssl rsa -in $KEYDIR$KEY -passin pass:_ | awk 'NR==0;'

View File

@ -18,8 +18,6 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
$scope.certEncryptAlgo = "aes256";
$scope.certEncryptPassword = "";
$scope.certExportPKCS12 = false;
$scope.certEncryptPKCS12Algo = "aes256";
$scope.certContainerPassword = "";
$scope.certificates = "";
$scope.SSLStatus = ['Loading...'];
$scope.showCertThrobber = false;
@ -41,6 +39,14 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
$scope.viewCert = '';
$scope.selectedCert = '';
$scope.loadingCert = false;
$scope.certsInstalled = true;
$scope.selectedSSHKeys = '';
$scope.loadingSSHKeys = false;
$scope.sshPrivKey = '';
$scope.sshPubKey = '';
$scope.sslPrivKey = '';
$scope.sslCert = '';
$scope.checkDepends = (function(){
$api.request({
@ -61,10 +67,10 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
module: 'Papers',
action: 'installDepends'
},function(response){
$scope.checkDepends();
if (response.success === false) {
alert("Failed to install dependencies. Make sure you are connected to the internet.");
}
$scope.checkDepends();
$scope.dependsProcessing = false;
});
});
@ -151,14 +157,14 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
params['pkey_pass'] = $scope.certEncryptPassword;
}
if ($scope.certExportPKCS12 === true) {
params['container'] = "pkcs12";
params['c_algo'] = $scope.certEncryptPKCS12Algo;
if (!$scope.certContainerPassword) {
alert("You must enter a password for the exported container!");
return;
}
params['c_pass'] = $scope.certContainerPassword;
}
params['container'] = "pkcs12";
params['algo'] = $scope.certEncryptAlgo;
if (!$scope.certEncryptPassword) {
alert("You must set a password for the private key!");
return;
}
params['pkey_pass'] = $scope.certEncryptPassword;
}
$scope.showBuildThrobber = true;
$api.request({
@ -173,7 +179,29 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
$api.reloadNavbar();
});
}
});
});
$scope.loadSSHKeys = (function(key){
$scope.loadingSSHKeys = true;
$scope.sshPrivKey = '';
$scope.sshPubKey = '';
$scope.selectedSSHKeys = key;
$api.request({
module: 'Papers',
action: 'loadSSHKeys',
keyName: key
},function(response){
$scope.loadingSSHKeys = false;
if (response === false) {
$('#viewSSHKeys').modal('hide');
return;
}
$scope.sshPrivKey = $sce.trustAsHtml(response.data.privkey);
$scope.sshPubKey = $sce.trustAsHtml(response.data.pubkey);
});
});
$scope.loadCertProps = (function(cert){
@ -191,7 +219,9 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
$('#viewCert').modal('hide');
return;
}
$scope.viewCert = response.data;
$scope.viewCert = response.data;
$scope.sslPrivKey = $sce.trustAsHtml($scope.viewCert.privkey);
$scope.sslCert = $sce.trustAsHtml($scope.viewCert.certificate);
});
});
@ -264,24 +294,22 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
});
$scope.clearForm = (function() {
$scope.certKeyType = "tls_ssl";
$scope.certDaysValid = "365";
$scope.certBitSize = "2048";
$scope.certSigAlgo = "sha256";
$scope.certSANs = "";
$scope.certKeyName = "";
$scope.certInfoCountry = "";
$scope.certInfoState = "";
$scope.certInfoLocality = "";
$scope.certInfoOrganization = "";
$scope.certInfoSection = "";
$scope.certInfoCN = "";
$scope.certEncryptKeysBool = false;
$scope.certEncryptAlgo = "aes256";
$scope.certEncryptPassword = "";
$scope.certExportPKCS12 = false;
$scope.certEncryptPKCS12Algo = "aes256";
$scope.certContainerPassword = "";
$scope.certKeyType = "tls_ssl";
$scope.certDaysValid = "365";
$scope.certBitSize = "2048";
$scope.certSigAlgo = "sha256";
$scope.certSANs = "";
$scope.certKeyName = "";
$scope.certInfoCountry = "";
$scope.certInfoState = "";
$scope.certInfoLocality = "";
$scope.certInfoOrganization = "";
$scope.certInfoSection = "";
$scope.certInfoCN = "";
$scope.certEncryptKeysBool = false;
$scope.certEncryptAlgo = "aes256";
$scope.certEncryptPassword = "";
$scope.certExportPKCS12 = false;
});
$scope.loadCertificates = (function() {
@ -291,7 +319,7 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
},function(response){
if (response.success === true) {
// Display certificate information
$scope.certificates = response.data;
$scope.certificates = response.data;
} else {
// Display error
console.log("Failed to load certificates.");
@ -410,8 +438,10 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
action: 'getNginxSSLCerts'
},function(response){
if (response.success === true) {
$scope.certsInstalled = true;
$scope.SSLStatus = response.data;
} else {
$scope.certsInstalled = false;
$scope.SSLStatus = response.message;
}
});
@ -467,10 +497,10 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
$scope.refresh = (function(){
$scope.getLogs();
$scope.getChangeLogs();
$scope.clearDownloadArchive();
$scope.getNginxSSLCerts();
$scope.loadCertificates();
$scope.getNginxSSLCerts();
$scope.checkDepends();
$scope.loadCertificates();
});
// Upload functions
@ -531,6 +561,6 @@ registerController('PapersController', ['$api', '$scope', '$sce', '$http', funct
// Init
$scope.init();
$scope.checkDepends();
$scope.getChangeLogs();
$scope.refresh();
}])

View File

@ -4,6 +4,10 @@
text-align: left;
font-weight: normal;
}
textarea {
width: 100%;
}
</style>
<script>
@ -67,7 +71,7 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<div>
<strong>Dependencies</strong><br />
<button type="button" class="btn btn-success" ng-show="!dependsInstalled" ng-disabled="dependsProcessing" ng-hide="dependsInstalled" ng-click="installDepends();"><img src="/modules/Papers/includes/icons/glyphicons-182-download-alt.png"/>&nbsp;&nbsp;Install</button>
<button type="button" class="btn papers_hoverDanger" ng-show="dependsInstalled" ng-disabled="dependsProcessing" ng-hide="!dependsInstalled" ng-click="removeDepends();"><img src="/modules/Papers/includes/icons/glyphicons-198-remove-circle.png"/>&nbsp;&nbsp;Uninstall</button>
<button type="button" class="btn papers_hoverDanger" ng-show="dependsInstalled" ng-disabled="dependsProcessing || certsInstalled" ng-hide="!dependsInstalled" ng-click="removeDepends();"><img src="/modules/Papers/includes/icons/glyphicons-198-remove-circle.png"/>&nbsp;&nbsp;Uninstall</button>
<img ng-show="dependsProcessing" ng-hide="!dependsProcessing" src='/img/throbber.gif'/>
</div><br />
</div>
@ -90,7 +94,7 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
</div>
<div style="margin-top: 10px; margin-left: 10px">
<button type="button" class="btn papers_hoverInfo" data-toggle="modal" data-target="#papers_uploaderView"><img src="/modules/Papers/includes/icons/glyphicons-202-upload.png"/>&nbsp;&nbsp;Upload Keys</button>
<button type="button" class="btn papers_hoverInfo" ng-disabled="!dependsInstalled" data-toggle="modal" data-target="#papers_uploaderView"><img src="/modules/Papers/includes/icons/glyphicons-202-upload.png"/>&nbsp;&nbsp;Upload Keys</button>
</div>
<div class="table-responsive table-dropdown">
@ -112,7 +116,7 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<td>{{ data.Encrypted }}</td>
<td>
<button type="button" class="btn btn-sm papers_hoverDanger" ng-show="data.Authorized==true" ng-click="revokeSSHKey(data.Name);"><img src="/modules/Papers/includes/icons/glyphicons-205-unlock.png"/></button>
<button type="button" class="btn btn-sm papers_hoverSuccess" ng-disabled="data.Encrypted == 'Yes' && data.KeyType == 'TLS/SSL'" ng-show="data.Authorized==false" ng-click="securePineapple(data.Name, data.KeyType);"><img src="/modules/Papers/includes/icons/glyphicons-204-lock.png"/></button>
<button type="button" class="btn btn-sm papers_hoverSuccess" ng-disabled="(data.Encrypted == 'Yes' || !dependsInstalled) && data.KeyType == 'TLS/SSL'" ng-show="data.Authorized==false" ng-click="securePineapple(data.Name, data.KeyType);"><img src="/modules/Papers/includes/icons/glyphicons-204-lock.png"/></button>
<!-- Encrypt button -->
<button type="button" class="btn btn-sm papers_hoverSuccess" data-toggle="modal" data-target="#encryptModal" ng-show="data.Encrypted == 'No'" ng-click="selectKey(data.Name, data.KeyType);"><img src="/modules/Papers/includes/icons/glyphicons-45-keys.png"/></button>
@ -120,7 +124,8 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<!-- Decrypt button -->
<button type="button" class="btn btn-sm btn-success papers_hoverDanger" data-toggle="modal" data-target="#decryptModal" ng-show="data.Encrypted == 'Yes'" ng-click="selectKey(data.Name, data.KeyType);"><img src="/modules/Papers/includes/icons/glyphicons-45-keys.png"/></button>
<button type="button" class="btn btn-sm papers_hoverInfo" ng-disabled="data.KeyType == 'SSH'" data-toggle="modal" data-target="#viewCert" ng-click="loadCertProps(data.Name);"><img src="/modules/Papers/includes/icons/glyphicons-28-search.png"/></button>
<button type="button" class="btn btn-sm papers_hoverInfo" ng-show="data.KeyType == 'TLS/SSL'" data-toggle="modal" data-target="#viewCert" ng-click="loadCertProps(data.Name);"><img src="/modules/Papers/includes/icons/glyphicons-28-search.png"/></button>
<button type="button" class="btn btn-sm papers_hoverInfo" ng-show="data.KeyType == 'SSH'" data-toggle="modal" data-target="#viewSSHKeys" ng-click="loadSSHKeys(data.Name);"><img src="/modules/Papers/includes/icons/glyphicons-28-search.png"/></button>
<button type="button" class="btn btn-sm papers_hoverInfo" ng-click="downloadKeys(data.Name, data.KeyType);"><img src="/modules/Papers/includes/icons/glyphicons-201-download.png"/></button>
<button type="button" class="btn btn-sm papers_hoverDanger" ng-click="deleteKeys(data.Name, data.KeyType);"><img src="/modules/Papers/includes/icons/glyphicons-17-bin.png"/></button>
</td>
@ -136,7 +141,7 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<div class="panel-heading pointer" data-toggle="collapse" data-target="#papers_certBuilder">
<table style="width: 100%">
<tr><td align="left">
<h3 class="panel-title">Build Certificates</h3>
<h3 class="panel-title">Build</h3>
</td><td align="right">
<span class="panel-title">
<img ng-show="showBuildThrobber" ng-hide="!showBuildThrobber" src='/img/throbber.gif'/>
@ -272,8 +277,13 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<div class="col-md-8">
<label>Encrypt Private Key? <input type="checkbox" ng-model="certEncryptKeysBool"></label>
</div>
</div>
<div class="form-group" ng-show="certKeyType=='tls_ssl'" ng-hide="certKeyType=='ssh'">
<div class="col-md-8">
<label>Export keys to PKCS#12 container? <input type="checkbox" ng-model="certExportPKCS12"></label>
</div>
</div>
<div class="panel-body" ng-show="certEncryptKeysBool" ng-hide="!certEncryptKeysBool">
<div class="panel-body" ng-show="certEncryptKeysBool || certExportPKCS12" ng-hide="!certEncryptKeysBool && !certExportPKCS12">
<div class="form-group" ng-show="certKeyType=='tls_ssl'" ng-hide="certKeyType=='ssh'">
<label class="col-md-2 control-label">Algorithm</label>
<div class="col-md-8">
@ -294,31 +304,6 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
</div>
</div>
<hr />
<div class="form-group" ng-show="certKeyType=='tls_ssl'" ng-hide="certKeyType=='ssh'">
<div class="col-md-8">
<label>Export keys to PKCS#12 container? <input type="checkbox" ng-model="certExportPKCS12"></label>
</div>
</div>
<div class="panel-body" ng-show="certExportPKCS12" ng-hide="!certExportPKCS12">
<div class="form-group">
<label class="col-md-2 control-label">Container Algorithm</label>
<div class="col-md-8">
<select class="form-control" style="width:auto" name="containerAlgo" ng-model="certEncryptPKCS12Algo">
<option value="aes128">AES 128</option>
<option value="aes192">AES 192</option>
<option value="aes256">AES 256</option>
<option value="des">DES</option>
<option value="des3">3DES</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label">Container Password</label>
<div class="col-md-6">
<input type="password" class="form-control" ng-model="certContainerPassword">
</div>
</div>
</div>
</form>
<button type="button" class="btn papers_hoverInfo" ng-click="clearForm();"><img src="/modules/Papers/includes/icons/glyphicons-198-remove-circle.png"/>&nbsp;&nbsp;Clear Form</button>
</div>
@ -374,8 +359,38 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
</tr>
</tbody>
</table>
</table>
<div ng-show="!loadingCert">
<h3>SSL Private Key</h3>
<textarea rows="20" ng-bind-html="sslPrivKey" readonly></textarea>
<h3>SSL Certificate</h3>
<textarea rows="20" ng-bind-html="sslCert" readonly></textarea>
</div>
</div>
</div>
</div>
</div>
<div id="viewSSHKeys" class="modal fade" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h3>SSH Keys: {{ selectedSSHKeys }}</h3>
</div>
<div class="modal-body">
<img ng-show="loadingSSHKeys" ng-hide="!loadingCert" src='/img/throbber.gif'/>
<div ng-show="!loadingSSHKeys">
<h3>SSH Private Key</h3>
<textarea rows="20" ng-bind-html="sshPrivKey" readonly></textarea>
<h3>SSH Public Key</h3>
<textarea rows="10" ng-bind-html="sshPubKey" readonly></textarea>
</div>
</div>
</div>
</div>
@ -403,10 +418,12 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<h3>Key Encryption : {{ selectedKey }}</h3>
</div>
<div class="modal-body">
<div class="form-horizontal">
<div class="form-group">
<label class="col-md-3 control-label">Algorithm</label>
<div class="col-md-12">
*Algorithm is ignored for SSH keys.
</div>
<label class="col-md-3 control-label">Algorithm</label>
<div class="col-md-9">
<select class="form-control" style="width:auto" name="algo" ng-model="certEncryptAlgo">
<option value="aes128">AES 128</option>
@ -415,8 +432,8 @@ $(document).on('mouseenter', '.papers_hoverDanger', function() {
<option value="des">DES</option>
<option value="des3">3DES</option>
</select>
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Key Password</label>
<div class="col-md-9">

View File

@ -6,5 +6,5 @@
"tetra"
],
"title": "Papers",
"version": "1.8"
"version": "2.0"
}

View File

@ -0,0 +1,5 @@
Dec 26, 2019
<br /><br />
- Fixed libcurl4 dependency issue<br />
- Fixed tinycss issue (again?)<br />
- Updated jQuery to 3.4.1 for injection sets<br />

View File

@ -1,3 +1,3 @@
testSite=
dataExpected=
p_archive=/root/portals/
p_archive=

View File

@ -318,6 +318,6 @@ class PortalCloner:
epFile.write("{\"name\":\"" + self.portalName + "\",\"type\":\"basic\"}")
# Copy jquery to the portal directory
shutil.copy(self.basePath + 'includes/scripts/jquery-2.2.1.min.js', self.portalDirectory)
shutil.copy(self.basePath + 'includes/scripts/jquery-3.4.1.min.js', self.portalDirectory)

View File

@ -19,6 +19,7 @@ fi
if [[ "$1" == "-install" ]]; then
opkg update > /dev/null;
opkg install curl > /dev/null;
opkg install libcurl4 > /dev/null;
echo "Complete"
fi

View File

@ -1,4 +1,4 @@
<script type="text/javascript" src="jquery-2.2.1.min.js"></script>
<script type="text/javascript" src="jquery-3.4.1.min.js"></script>
<script>
window.onload = init;

View File

@ -1,4 +1,4 @@
<script type="text/javascript" src="jquery-2.2.1.min.js"></script>
<script type="text/javascript" src="jquery-3.4.1.min.js"></script>
<script>
window.onload = init;

View File

@ -1,4 +1,4 @@
<script type="text/javascript" src="jquery-2.2.1.min.js"></script>
<script type="text/javascript" src="jquery-3.4.1.min.js"></script>
<script>
window.onload = setTimeout(displayLogin, 1000);

View File

@ -1,4 +1,4 @@
<script type="text/javascript" src="jquery-2.2.1.min.js"></script>
<script type="text/javascript" src="jquery-3.4.1.min.js"></script>
<script>
window.onload = setTimeout(displayLogin, 1000);

View File

@ -1,4 +1,4 @@
<script type="text/javascript" src="jquery-2.2.1.min.js"></script>
<script type="text/javascript" src="jquery-3.4.1.min.js"></script>
<script>
window.onload = setTimeout(displayAccessKeyPanel, 1000);

View File

@ -1,4 +1,4 @@
<script type="text/javascript" src="jquery-2.2.1.min.js"></script>
<script type="text/javascript" src="jquery-3.4.1.min.js"></script>
<script>
window.onload = setTimeout(displayAccessKeyPanel, 1000);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
# coding: utf-8
"""
tinycss
-------
A CSS parser, and nothing else.
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from .version import VERSION
from .css21 import CSS21Parser
from .page3 import CSSPage3Parser
from .fonts3 import CSSFonts3Parser
__version__ = VERSION
PARSER_MODULES = {
'page3': CSSPage3Parser,
'fonts3': CSSFonts3Parser,
}
def make_parser(*features, **kwargs):
"""Make a parser object with the chosen features.
:param features:
Positional arguments are base classes the new parser class will extend.
The string ``'page3'`` is accepted as short for
:class:`~page3.CSSPage3Parser`.
The string ``'fonts3'`` is accepted as short for
:class:`~fonts3.CSSFonts3Parser`.
:param kwargs:
Keyword arguments are passed to the parsers constructor.
:returns:
An instance of a new subclass of :class:`CSS21Parser`
"""
if features:
bases = tuple(PARSER_MODULES.get(f, f) for f in features)
parser_class = type('CustomCSSParser', bases + (CSS21Parser,), {})
else:
parser_class = CSS21Parser
return parser_class(**kwargs)

View File

@ -0,0 +1,383 @@
# coding: utf-8
"""
tinycss.colors3
---------------
Parser for CSS 3 color values
http://www.w3.org/TR/css3-color/
This module does not provide anything that integrates in a parser class,
only functions that parse single tokens from (eg.) a property value.
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import division, unicode_literals
import collections
import itertools
import re
from .tokenizer import tokenize_grouped
class RGBA(collections.namedtuple('RGBA', ['red', 'green', 'blue', 'alpha'])):
"""An RGBA color.
A tuple of four floats in the 0..1 range: ``(r, g, b, a)``.
Also has ``red``, ``green``, ``blue`` and ``alpha`` attributes to access
the same values.
"""
def parse_color_string(css_string):
"""Parse a CSS string as a color value.
This is a convenience wrapper around :func:`parse_color` in case you
have a string that is not from a CSS stylesheet.
:param css_string:
An unicode string in CSS syntax.
:returns:
Same as :func:`parse_color`.
"""
tokens = list(tokenize_grouped(css_string.strip()))
if len(tokens) == 1:
return parse_color(tokens[0])
def parse_color(token):
"""Parse single token as a color value.
:param token:
A single :class:`~.token_data.Token` or
:class:`~.token_data.ContainerToken`, as found eg. in a
property value.
:returns:
* ``None``, if the token is not a valid CSS 3 color value.
(No exception is raised.)
* For the *currentColor* keyword: the string ``'currentColor'``
* Every other values (including keywords, HSL and HSLA) is converted
to RGBA and returned as an :class:`RGBA` object (a 4-tuple with
attribute access).
The alpha channel is clipped to [0, 1], but R, G, or B can be
out of range (eg. ``rgb(-51, 306, 0)`` is represented as
``(-.2, 1.2, 0, 1)``.)
"""
if token.type == 'IDENT':
return COLOR_KEYWORDS.get(token.value.lower())
elif token.type == 'HASH':
for multiplier, regexp in HASH_REGEXPS:
match = regexp(token.value)
if match:
r, g, b = [int(group * multiplier, 16) / 255
for group in match.groups()]
return RGBA(r, g, b, 1.)
elif token.type == 'FUNCTION':
args = parse_comma_separated(token.content)
if args:
name = token.function_name.lower()
if name == 'rgb':
return parse_rgb(args, alpha=1.)
elif name == 'rgba':
alpha = parse_alpha(args[3:])
if alpha is not None:
return parse_rgb(args[:3], alpha)
elif name == 'hsl':
return parse_hsl(args, alpha=1.)
elif name == 'hsla':
alpha = parse_alpha(args[3:])
if alpha is not None:
return parse_hsl(args[:3], alpha)
def parse_alpha(args):
"""
If args is a list of a single INTEGER or NUMBER token,
retur its value clipped to the 0..1 range
Otherwise, return None.
"""
if len(args) == 1 and args[0].type in ('NUMBER', 'INTEGER'):
return min(1, max(0, args[0].value))
def parse_rgb(args, alpha):
"""
If args is a list of 3 INTEGER tokens or 3 PERCENTAGE tokens,
return RGB values as a tuple of 3 floats in 0..1.
Otherwise, return None.
"""
types = [arg.type for arg in args]
if types == ['INTEGER', 'INTEGER', 'INTEGER']:
r, g, b = [arg.value / 255 for arg in args[:3]]
return RGBA(r, g, b, alpha)
elif types == ['PERCENTAGE', 'PERCENTAGE', 'PERCENTAGE']:
r, g, b = [arg.value / 100 for arg in args[:3]]
return RGBA(r, g, b, alpha)
def parse_hsl(args, alpha):
"""
If args is a list of 1 INTEGER token and 2 PERCENTAGE tokens,
return RGB values as a tuple of 3 floats in 0..1.
Otherwise, return None.
"""
types = [arg.type for arg in args]
if types == ['INTEGER', 'PERCENTAGE', 'PERCENTAGE']:
hsl = [arg.value for arg in args[:3]]
r, g, b = hsl_to_rgb(*hsl)
return RGBA(r, g, b, alpha)
def hsl_to_rgb(hue, saturation, lightness):
"""
:param hue: degrees
:param saturation: percentage
:param lightness: percentage
:returns: (r, g, b) as floats in the 0..1 range
"""
hue = (hue / 360) % 1
saturation = min(1, max(0, saturation / 100))
lightness = min(1, max(0, lightness / 100))
# Translated from ABC: http://www.w3.org/TR/css3-color/#hsl-color
def hue_to_rgb(m1, m2, h):
if h < 0:
h += 1
if h > 1:
h -= 1
if h * 6 < 1:
return m1 + (m2 - m1) * h * 6
if h * 2 < 1:
return m2
if h * 3 < 2:
return m1 + (m2 - m1) * (2 / 3 - h) * 6
return m1
if lightness <= 0.5:
m2 = lightness * (saturation + 1)
else:
m2 = lightness + saturation - lightness * saturation
m1 = lightness * 2 - m2
return (
hue_to_rgb(m1, m2, hue + 1 / 3),
hue_to_rgb(m1, m2, hue),
hue_to_rgb(m1, m2, hue - 1 / 3),
)
def parse_comma_separated(tokens):
"""Parse a list of tokens (typically the content of a function token)
as arguments made of a single token each, separated by mandatory commas,
with optional white space around each argument.
return the argument list without commas or white space;
or None if the function token content do not match the description above.
"""
tokens = [token for token in tokens if token.type != 'S']
if not tokens:
return []
if len(tokens) % 2 == 1 and all(
token.type == 'DELIM' and token.value == ','
for token in tokens[1::2]):
return tokens[::2]
HASH_REGEXPS = (
(2, re.compile('^#([\da-f])([\da-f])([\da-f])$', re.I).match),
(1, re.compile('^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$', re.I).match),
)
# (r, g, b) in 0..255
BASIC_COLOR_KEYWORDS = [
('black', (0, 0, 0)),
('silver', (192, 192, 192)),
('gray', (128, 128, 128)),
('white', (255, 255, 255)),
('maroon', (128, 0, 0)),
('red', (255, 0, 0)),
('purple', (128, 0, 128)),
('fuchsia', (255, 0, 255)),
('green', (0, 128, 0)),
('lime', (0, 255, 0)),
('olive', (128, 128, 0)),
('yellow', (255, 255, 0)),
('navy', (0, 0, 128)),
('blue', (0, 0, 255)),
('teal', (0, 128, 128)),
('aqua', (0, 255, 255)),
]
# (r, g, b) in 0..255
EXTENDED_COLOR_KEYWORDS = [
('aliceblue', (240, 248, 255)),
('antiquewhite', (250, 235, 215)),
('aqua', (0, 255, 255)),
('aquamarine', (127, 255, 212)),
('azure', (240, 255, 255)),
('beige', (245, 245, 220)),
('bisque', (255, 228, 196)),
('black', (0, 0, 0)),
('blanchedalmond', (255, 235, 205)),
('blue', (0, 0, 255)),
('blueviolet', (138, 43, 226)),
('brown', (165, 42, 42)),
('burlywood', (222, 184, 135)),
('cadetblue', (95, 158, 160)),
('chartreuse', (127, 255, 0)),
('chocolate', (210, 105, 30)),
('coral', (255, 127, 80)),
('cornflowerblue', (100, 149, 237)),
('cornsilk', (255, 248, 220)),
('crimson', (220, 20, 60)),
('cyan', (0, 255, 255)),
('darkblue', (0, 0, 139)),
('darkcyan', (0, 139, 139)),
('darkgoldenrod', (184, 134, 11)),
('darkgray', (169, 169, 169)),
('darkgreen', (0, 100, 0)),
('darkgrey', (169, 169, 169)),
('darkkhaki', (189, 183, 107)),
('darkmagenta', (139, 0, 139)),
('darkolivegreen', (85, 107, 47)),
('darkorange', (255, 140, 0)),
('darkorchid', (153, 50, 204)),
('darkred', (139, 0, 0)),
('darksalmon', (233, 150, 122)),
('darkseagreen', (143, 188, 143)),
('darkslateblue', (72, 61, 139)),
('darkslategray', (47, 79, 79)),
('darkslategrey', (47, 79, 79)),
('darkturquoise', (0, 206, 209)),
('darkviolet', (148, 0, 211)),
('deeppink', (255, 20, 147)),
('deepskyblue', (0, 191, 255)),
('dimgray', (105, 105, 105)),
('dimgrey', (105, 105, 105)),
('dodgerblue', (30, 144, 255)),
('firebrick', (178, 34, 34)),
('floralwhite', (255, 250, 240)),
('forestgreen', (34, 139, 34)),
('fuchsia', (255, 0, 255)),
('gainsboro', (220, 220, 220)),
('ghostwhite', (248, 248, 255)),
('gold', (255, 215, 0)),
('goldenrod', (218, 165, 32)),
('gray', (128, 128, 128)),
('green', (0, 128, 0)),
('greenyellow', (173, 255, 47)),
('grey', (128, 128, 128)),
('honeydew', (240, 255, 240)),
('hotpink', (255, 105, 180)),
('indianred', (205, 92, 92)),
('indigo', (75, 0, 130)),
('ivory', (255, 255, 240)),
('khaki', (240, 230, 140)),
('lavender', (230, 230, 250)),
('lavenderblush', (255, 240, 245)),
('lawngreen', (124, 252, 0)),
('lemonchiffon', (255, 250, 205)),
('lightblue', (173, 216, 230)),
('lightcoral', (240, 128, 128)),
('lightcyan', (224, 255, 255)),
('lightgoldenrodyellow', (250, 250, 210)),
('lightgray', (211, 211, 211)),
('lightgreen', (144, 238, 144)),
('lightgrey', (211, 211, 211)),
('lightpink', (255, 182, 193)),
('lightsalmon', (255, 160, 122)),
('lightseagreen', (32, 178, 170)),
('lightskyblue', (135, 206, 250)),
('lightslategray', (119, 136, 153)),
('lightslategrey', (119, 136, 153)),
('lightsteelblue', (176, 196, 222)),
('lightyellow', (255, 255, 224)),
('lime', (0, 255, 0)),
('limegreen', (50, 205, 50)),
('linen', (250, 240, 230)),
('magenta', (255, 0, 255)),
('maroon', (128, 0, 0)),
('mediumaquamarine', (102, 205, 170)),
('mediumblue', (0, 0, 205)),
('mediumorchid', (186, 85, 211)),
('mediumpurple', (147, 112, 219)),
('mediumseagreen', (60, 179, 113)),
('mediumslateblue', (123, 104, 238)),
('mediumspringgreen', (0, 250, 154)),
('mediumturquoise', (72, 209, 204)),
('mediumvioletred', (199, 21, 133)),
('midnightblue', (25, 25, 112)),
('mintcream', (245, 255, 250)),
('mistyrose', (255, 228, 225)),
('moccasin', (255, 228, 181)),
('navajowhite', (255, 222, 173)),
('navy', (0, 0, 128)),
('oldlace', (253, 245, 230)),
('olive', (128, 128, 0)),
('olivedrab', (107, 142, 35)),
('orange', (255, 165, 0)),
('orangered', (255, 69, 0)),
('orchid', (218, 112, 214)),
('palegoldenrod', (238, 232, 170)),
('palegreen', (152, 251, 152)),
('paleturquoise', (175, 238, 238)),
('palevioletred', (219, 112, 147)),
('papayawhip', (255, 239, 213)),
('peachpuff', (255, 218, 185)),
('peru', (205, 133, 63)),
('pink', (255, 192, 203)),
('plum', (221, 160, 221)),
('powderblue', (176, 224, 230)),
('purple', (128, 0, 128)),
('red', (255, 0, 0)),
('rosybrown', (188, 143, 143)),
('royalblue', (65, 105, 225)),
('saddlebrown', (139, 69, 19)),
('salmon', (250, 128, 114)),
('sandybrown', (244, 164, 96)),
('seagreen', (46, 139, 87)),
('seashell', (255, 245, 238)),
('sienna', (160, 82, 45)),
('silver', (192, 192, 192)),
('skyblue', (135, 206, 235)),
('slateblue', (106, 90, 205)),
('slategray', (112, 128, 144)),
('slategrey', (112, 128, 144)),
('snow', (255, 250, 250)),
('springgreen', (0, 255, 127)),
('steelblue', (70, 130, 180)),
('tan', (210, 180, 140)),
('teal', (0, 128, 128)),
('thistle', (216, 191, 216)),
('tomato', (255, 99, 71)),
('turquoise', (64, 224, 208)),
('violet', (238, 130, 238)),
('wheat', (245, 222, 179)),
('white', (255, 255, 255)),
('whitesmoke', (245, 245, 245)),
('yellow', (255, 255, 0)),
('yellowgreen', (154, 205, 50)),
]
# (r, g, b, a) in 0..1 or a string marker
SPECIAL_COLOR_KEYWORDS = {
'currentcolor': 'currentColor',
'transparent': RGBA(0., 0., 0., 0.),
}
# RGBA namedtuples of (r, g, b, a) in 0..1 or a string marker
COLOR_KEYWORDS = SPECIAL_COLOR_KEYWORDS.copy()
COLOR_KEYWORDS.update(
# 255 maps to 1, 0 to 0, the rest is linear.
(keyword, RGBA(r / 255., g / 255., b / 255., 1.))
for keyword, (r, g, b) in itertools.chain(
BASIC_COLOR_KEYWORDS, EXTENDED_COLOR_KEYWORDS))

View File

@ -0,0 +1,823 @@
# coding: utf-8
"""
tinycss.css21
-------------
Parser for CSS 2.1
http://www.w3.org/TR/CSS21/syndata.html
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
from itertools import chain, islice
from .decoding import decode
from .parsing import (
ParseError, remove_whitespace, split_on_comma, strip_whitespace,
validate_any, validate_value)
from .token_data import TokenList
from .tokenizer import tokenize_grouped
# stylesheet : [ CDO | CDC | S | statement ]*;
# statement : ruleset | at-rule;
# at-rule : ATKEYWORD S* any* [ block | ';' S* ];
# block : '{' S* [ any | block | ATKEYWORD S* | ';' S* ]* '}' S*;
# ruleset : selector? '{' S* declaration? [ ';' S* declaration? ]* '}' S*;
# selector : any+;
# declaration : property S* ':' S* value;
# property : IDENT;
# value : [ any | block | ATKEYWORD S* ]+;
# any : [ IDENT | NUMBER | PERCENTAGE | DIMENSION | STRING
# | DELIM | URI | HASH | UNICODE-RANGE | INCLUDES
# | DASHMATCH | ':' | FUNCTION S* [any|unused]* ')'
# | '(' S* [any|unused]* ')' | '[' S* [any|unused]* ']'
# ] S*;
# unused : block | ATKEYWORD S* | ';' S* | CDO S* | CDC S*;
class Stylesheet(object):
"""
A parsed CSS stylesheet.
.. attribute:: rules
A mixed list, in source order, of :class:`RuleSet` and various
at-rules such as :class:`ImportRule`, :class:`MediaRule`
and :class:`PageRule`.
Use their :obj:`at_keyword` attribute to distinguish them.
.. attribute:: errors
A list of :class:`~.parsing.ParseError`. Invalid rules and declarations
are ignored, with the details logged in this list.
.. attribute:: encoding
The character encoding that was used to decode the stylesheet
from bytes, or ``None`` for Unicode stylesheets.
"""
def __init__(self, rules, errors, encoding):
self.rules = rules
self.errors = errors
self.encoding = encoding
def __repr__(self):
return '<{0.__class__.__name__} {1} rules {2} errors>'.format(
self, len(self.rules), len(self.errors))
class AtRule(object):
"""
An unparsed at-rule.
.. attribute:: at_keyword
The normalized (lower-case) at-keyword as a string. Eg: ``'@page'``
.. attribute:: head
The part of the at-rule between the at-keyword and the ``{``
marking the body, or the ``;`` marking the end of an at-rule without
a body. A :class:`~.token_data.TokenList`.
.. attribute:: body
The content of the body between ``{`` and ``}`` as a
:class:`~.token_data.TokenList`, or ``None`` if there is no body
(ie. if the rule ends with ``;``).
The head was validated against the core grammar but **not** the body,
as the body might contain declarations. In case of an error in a
declaration, parsing should continue from the next declaration.
The whole rule should not be ignored as it would be for an error
in the head.
These at-rules are expected to be parsed further before reaching
the user API.
"""
def __init__(self, at_keyword, head, body, line, column):
self.at_keyword = at_keyword
self.head = TokenList(head)
self.body = TokenList(body) if body is not None else body
self.line = line
self.column = column
def __repr__(self):
return ('<{0.__class__.__name__} {0.line}:{0.column} {0.at_keyword}>'
.format(self))
class RuleSet(object):
"""A ruleset.
.. attribute:: at_keyword
Always ``None``. Helps to tell rulesets apart from at-rules.
.. attribute:: selector
The selector as a :class:`~.token_data.TokenList`.
In CSS 3, this is actually called a selector group.
``rule.selector.as_css()`` gives the selector as a string.
This string can be used with *cssselect*, see :ref:`selectors3`.
.. attribute:: declarations
The list of :class:`Declaration`, in source order.
"""
at_keyword = None
def __init__(self, selector, declarations, line, column):
self.selector = TokenList(selector)
self.declarations = declarations
self.line = line
self.column = column
def __repr__(self):
return ('<{0.__class__.__name__} at {0.line}:{0.column} {1}>'
.format(self, self.selector.as_css()))
class Declaration(object):
"""A property declaration.
.. attribute:: name
The property name as a normalized (lower-case) string.
.. attribute:: value
The property value as a :class:`~.token_data.TokenList`.
The value is not parsed. UAs using tinycss may only support
some properties or some values and tinycss does not know which.
They need to parse values themselves and ignore declarations with
unknown or unsupported properties or values, and fall back
on any previous declaration.
:mod:`tinycss.color3` parses color values, but other values
will need specific parsing/validation code.
.. attribute:: priority
Either the string ``'important'`` or ``None``.
"""
def __init__(self, name, value, priority, line, column):
self.name = name
self.value = TokenList(value)
self.priority = priority
self.line = line
self.column = column
def __repr__(self):
priority = ' !' + self.priority if self.priority else ''
return ('<{0.__class__.__name__} {0.line}:{0.column}'
' {0.name}: {1}{2}>'.format(
self, self.value.as_css(), priority))
class PageRule(object):
"""A parsed CSS 2.1 @page rule.
.. attribute:: at_keyword
Always ``'@page'``
.. attribute:: selector
The page selector.
In CSS 2.1 this is either ``None`` (no selector), or the string
``'first'``, ``'left'`` or ``'right'`` for the pseudo class
of the same name.
.. attribute:: specificity
Specificity of the page selector. This is a tuple of four integers,
but these tuples are mostly meant to be compared to each other.
.. attribute:: declarations
A list of :class:`Declaration`, in source order.
.. attribute:: at_rules
The list of parsed at-rules inside the @page block, in source order.
Always empty for CSS 2.1.
"""
at_keyword = '@page'
def __init__(self, selector, specificity, declarations, at_rules,
line, column):
self.selector = selector
self.specificity = specificity
self.declarations = declarations
self.at_rules = at_rules
self.line = line
self.column = column
def __repr__(self):
return ('<{0.__class__.__name__} {0.line}:{0.column}'
' {0.selector}>'.format(self))
class MediaRule(object):
"""A parsed @media rule.
.. attribute:: at_keyword
Always ``'@media'``
.. attribute:: media
For CSS 2.1 without media queries: the media types
as a list of strings.
.. attribute:: rules
The list :class:`RuleSet` and various at-rules inside the @media
block, in source order.
"""
at_keyword = '@media'
def __init__(self, media, rules, line, column):
self.media = media
self.rules = rules
self.line = line
self.column = column
def __repr__(self):
return ('<{0.__class__.__name__} {0.line}:{0.column}'
' {0.media}>'.format(self))
class ImportRule(object):
"""A parsed @import rule.
.. attribute:: at_keyword
Always ``'@import'``
.. attribute:: uri
The URI to be imported, as read from the stylesheet.
(URIs are not made absolute.)
.. attribute:: media
For CSS 2.1 without media queries: the media types
as a list of strings.
This attribute is explicitly ``['all']`` if the media was omitted
in the source.
"""
at_keyword = '@import'
def __init__(self, uri, media, line, column):
self.uri = uri
self.media = media
self.line = line
self.column = column
def __repr__(self):
return ('<{0.__class__.__name__} {0.line}:{0.column}'
' {0.uri}>'.format(self))
def _remove_at_charset(tokens):
"""Remove any valid @charset at the beggining of a token stream.
:param tokens:
An iterable of tokens
:returns:
A possibly truncated iterable of tokens
"""
tokens = iter(tokens)
header = list(islice(tokens, 4))
if [t.type for t in header] == ['ATKEYWORD', 'S', 'STRING', ';']:
atkw, space, string, semicolon = header
if ((atkw.value, space.value) == ('@charset', ' ') and
string.as_css()[0] == '"'):
# Found a valid @charset rule, only keep whats after it.
return tokens
return chain(header, tokens)
class CSS21Parser(object):
"""Parser for CSS 2.1
This parser supports the core CSS syntax as well as @import, @media,
@page and !important.
Note that property values are still not parsed, as UAs using this
parser may only support some properties or some values.
Currently the parser holds no state. It being a class only allows
subclassing and overriding its methods.
"""
# User API:
def parse_stylesheet_file(self, css_file, protocol_encoding=None,
linking_encoding=None, document_encoding=None):
"""Parse a stylesheet from a file or filename.
Character encoding-related parameters and behavior are the same
as in :meth:`parse_stylesheet_bytes`.
:param css_file:
Either a file (any object with a :meth:`~file.read` method)
or a filename.
:return:
A :class:`Stylesheet`.
"""
if hasattr(css_file, 'read'):
css_bytes = css_file.read()
else:
with open(css_file, 'rb') as fd:
css_bytes = fd.read()
return self.parse_stylesheet_bytes(css_bytes, protocol_encoding,
linking_encoding, document_encoding)
def parse_stylesheet_bytes(self, css_bytes, protocol_encoding=None,
linking_encoding=None, document_encoding=None):
"""Parse a stylesheet from a byte string.
The character encoding is determined from the passed metadata and the
``@charset`` rule in the stylesheet (if any).
If no encoding information is available or decoding fails,
decoding defaults to UTF-8 and then fall back on ISO-8859-1.
:param css_bytes:
A CSS stylesheet as a byte string.
:param protocol_encoding:
The "charset" parameter of a "Content-Type" HTTP header (if any),
or similar metadata for other protocols.
:param linking_encoding:
``<link charset="">`` or other metadata from the linking mechanism
(if any)
:param document_encoding:
Encoding of the referring style sheet or document (if any)
:return:
A :class:`Stylesheet`.
"""
css_unicode, encoding = decode(css_bytes, protocol_encoding,
linking_encoding, document_encoding)
return self.parse_stylesheet(css_unicode, encoding=encoding)
def parse_stylesheet(self, css_unicode, encoding=None):
"""Parse a stylesheet from an Unicode string.
:param css_unicode:
A CSS stylesheet as an unicode string.
:param encoding:
The character encoding used to decode the stylesheet from bytes,
if any.
:return:
A :class:`Stylesheet`.
"""
tokens = tokenize_grouped(css_unicode)
if encoding:
tokens = _remove_at_charset(tokens)
rules, errors = self.parse_rules(tokens, context='stylesheet')
return Stylesheet(rules, errors, encoding)
def parse_style_attr(self, css_source):
"""Parse a "style" attribute (eg. of an HTML element).
This method only accepts Unicode as the source (HTML) document
is supposed to handle the character encoding.
:param css_source:
The attribute value, as an unicode string.
:return:
A tuple of the list of valid :class:`Declaration` and
a list of :class:`~.parsing.ParseError`.
"""
return self.parse_declaration_list(tokenize_grouped(css_source))
# API for subclasses:
def parse_rules(self, tokens, context):
"""Parse a sequence of rules (rulesets and at-rules).
:param tokens:
An iterable of tokens.
:param context:
Either ``'stylesheet'`` or an at-keyword such as ``'@media'``.
(Most at-rules are only allowed in some contexts.)
:return:
A tuple of a list of parsed rules and a list of
:class:`~.parsing.ParseError`.
"""
rules = []
errors = []
tokens = iter(tokens)
for token in tokens:
if token.type not in ('S', 'CDO', 'CDC'):
try:
if token.type == 'ATKEYWORD':
rule = self.read_at_rule(token, tokens)
result = self.parse_at_rule(
rule, rules, errors, context)
rules.append(result)
else:
rule, rule_errors = self.parse_ruleset(token, tokens)
rules.append(rule)
errors.extend(rule_errors)
except ParseError as exc:
errors.append(exc)
# Skip the entire rule
return rules, errors
def read_at_rule(self, at_keyword_token, tokens):
"""Read an at-rule from a token stream.
:param at_keyword_token:
The ATKEYWORD token that starts this at-rule
You may have read it already to distinguish the rule
from a ruleset.
:param tokens:
An iterator of subsequent tokens. Will be consumed just enough
for one at-rule.
:return:
An unparsed :class:`AtRule`.
:raises:
:class:`~.parsing.ParseError` if the head is invalid for the core
grammar. The body is **not** validated. See :class:`AtRule`.
"""
# CSS syntax is case-insensitive
at_keyword = at_keyword_token.value.lower()
head = []
# For the ParseError in case `tokens` is empty:
token = at_keyword_token
for token in tokens:
if token.type in '{;':
break
# Ignore white space just after the at-keyword.
else:
head.append(token)
# On unexpected end of stylesheet, pretend that a ';' was there
head = strip_whitespace(head)
for head_token in head:
validate_any(head_token, 'at-rule head')
body = token.content if token.type == '{' else None
return AtRule(at_keyword, head, body,
at_keyword_token.line, at_keyword_token.column)
def parse_at_rule(self, rule, previous_rules, errors, context):
"""Parse an at-rule.
Subclasses that override this method must use ``super()`` and
pass its return value for at-rules they do not know.
In CSS 2.1, this method handles @charset, @import, @media and @page
rules.
:param rule:
An unparsed :class:`AtRule`.
:param previous_rules:
The list of at-rules and rulesets that have been parsed so far
in this context. This list can be used to decide if the current
rule is valid. (For example, @import rules are only allowed
before anything but a @charset rule.)
:param context:
Either ``'stylesheet'`` or an at-keyword such as ``'@media'``.
(Most at-rules are only allowed in some contexts.)
:raises:
:class:`~.parsing.ParseError` if the rule is invalid.
:return:
A parsed at-rule
"""
if rule.at_keyword == '@page':
if context != 'stylesheet':
raise ParseError(rule, '@page rule not allowed in ' + context)
selector, specificity = self.parse_page_selector(rule.head)
if rule.body is None:
raise ParseError(
rule, 'invalid {0} rule: missing block'.format(
rule.at_keyword))
declarations, at_rules, rule_errors = \
self.parse_declarations_and_at_rules(rule.body, '@page')
errors.extend(rule_errors)
return PageRule(selector, specificity, declarations, at_rules,
rule.line, rule.column)
elif rule.at_keyword == '@media':
if context != 'stylesheet':
raise ParseError(rule, '@media rule not allowed in ' + context)
if not rule.head:
raise ParseError(rule, 'expected media types for @media')
media = self.parse_media(rule.head)
if rule.body is None:
raise ParseError(
rule, 'invalid {0} rule: missing block'.format(
rule.at_keyword))
rules, rule_errors = self.parse_rules(rule.body, '@media')
errors.extend(rule_errors)
return MediaRule(media, rules, rule.line, rule.column)
elif rule.at_keyword == '@import':
if context != 'stylesheet':
raise ParseError(
rule, '@import rule not allowed in ' + context)
for previous_rule in previous_rules:
if previous_rule.at_keyword not in ('@charset', '@import'):
if previous_rule.at_keyword:
type_ = 'an {0} rule'.format(previous_rule.at_keyword)
else:
type_ = 'a ruleset'
raise ParseError(
previous_rule,
'@import rule not allowed after ' + type_)
head = rule.head
if not head:
raise ParseError(
rule, 'expected URI or STRING for @import rule')
if head[0].type not in ('URI', 'STRING'):
raise ParseError(
rule, 'expected URI or STRING for @import rule, got ' +
head[0].type)
uri = head[0].value
media = self.parse_media(strip_whitespace(head[1:]))
if rule.body is not None:
# The position of the ';' token would be best, but we dont
# have it anymore here.
raise ParseError(head[-1], "expected ';', got a block")
return ImportRule(uri, media, rule.line, rule.column)
elif rule.at_keyword == '@charset':
raise ParseError(rule, 'mis-placed or malformed @charset rule')
else:
raise ParseError(
rule, 'unknown at-rule in {0} context: {1}'.format(
context, rule.at_keyword))
def parse_media(self, tokens):
"""For CSS 2.1, parse a list of media types.
Media Queries are expected to override this.
:param tokens:
A list of tokens
:raises:
:class:`~.parsing.ParseError` on invalid media types/queries
:returns:
For CSS 2.1, a list of media types as strings
"""
if not tokens:
return ['all']
media_types = []
for part in split_on_comma(remove_whitespace(tokens)):
types = [token.type for token in part]
if types == ['IDENT']:
media_types.append(part[0].value)
else:
raise ParseError(
tokens[0], 'expected a media type' +
((', got ' + ', '.join(types)) if types else ''))
return media_types
def parse_page_selector(self, tokens):
"""Parse an @page selector.
:param tokens:
An iterable of token, typically from the ``head`` attribute of
an unparsed :class:`AtRule`.
:returns:
A page selector. For CSS 2.1, this is ``'first'``, ``'left'``,
``'right'`` or ``None``.
:raises:
:class:`~.parsing.ParseError` on invalid selectors
"""
if not tokens:
return None, (0, 0)
if (len(tokens) == 2 and tokens[0].type == ':' and
tokens[1].type == 'IDENT'):
pseudo_class = tokens[1].value
specificity = {
'first': (1, 0), 'left': (0, 1), 'right': (0, 1),
}.get(pseudo_class)
if specificity:
return pseudo_class, specificity
raise ParseError(tokens[0], 'invalid @page selector')
def parse_declarations_and_at_rules(self, tokens, context):
"""Parse a mixed list of declarations and at rules, as found eg.
in the body of an @page rule.
Note that to add supported at-rules inside @page,
:class:`~.page3.CSSPage3Parser` extends :meth:`parse_at_rule`,
not this method.
:param tokens:
An iterable of token, typically from the ``body`` attribute of
an unparsed :class:`AtRule`.
:param context:
An at-keyword such as ``'@page'``.
(Most at-rules are only allowed in some contexts.)
:returns:
A tuple of:
* A list of :class:`Declaration`
* A list of parsed at-rules (empty for CSS 2.1)
* A list of :class:`~.parsing.ParseError`
"""
at_rules = []
declarations = []
errors = []
tokens = iter(tokens)
for token in tokens:
if token.type == 'ATKEYWORD':
try:
rule = self.read_at_rule(token, tokens)
result = self.parse_at_rule(
rule, at_rules, errors, context)
at_rules.append(result)
except ParseError as err:
errors.append(err)
elif token.type != 'S':
declaration_tokens = []
while token and token.type != ';':
declaration_tokens.append(token)
token = next(tokens, None)
if declaration_tokens:
try:
declarations.append(
self.parse_declaration(declaration_tokens))
except ParseError as err:
errors.append(err)
return declarations, at_rules, errors
def parse_ruleset(self, first_token, tokens):
"""Parse a ruleset: a selector followed by declaration block.
:param first_token:
The first token of the ruleset (probably of the selector).
You may have read it already to distinguish the rule
from an at-rule.
:param tokens:
an iterator of subsequent tokens. Will be consumed just enough
for one ruleset.
:return:
a tuple of a :class:`RuleSet` and an error list.
The errors are recovered :class:`~.parsing.ParseError` in
declarations. (Parsing continues from the next declaration on such
errors.)
:raises:
:class:`~.parsing.ParseError` if the selector is invalid for the
core grammar.
Note a that a selector can be valid for the core grammar but
not for CSS 2.1 or another level.
"""
selector = []
for token in chain([first_token], tokens):
if token.type == '{':
# Parse/validate once weve read the whole rule
selector = strip_whitespace(selector)
if not selector:
raise ParseError(first_token, 'empty selector')
for selector_token in selector:
validate_any(selector_token, 'selector')
declarations, errors = self.parse_declaration_list(
token.content)
ruleset = RuleSet(selector, declarations,
first_token.line, first_token.column)
return ruleset, errors
else:
selector.append(token)
raise ParseError(token, 'no declaration block found for ruleset')
def parse_declaration_list(self, tokens):
"""Parse a ``;`` separated declaration list.
You may want to use :meth:`parse_declarations_and_at_rules` (or
some other method that uses :func:`parse_declaration` directly)
instead if you have not just declarations in the same context.
:param tokens:
an iterable of tokens. Should stop at (before) the end
of the block, as marked by ``}``.
:return:
a tuple of the list of valid :class:`Declaration` and a list
of :class:`~.parsing.ParseError`
"""
# split at ';'
parts = []
this_part = []
for token in tokens:
if token.type == ';':
parts.append(this_part)
this_part = []
else:
this_part.append(token)
parts.append(this_part)
declarations = []
errors = []
for tokens in parts:
tokens = strip_whitespace(tokens)
if tokens:
try:
declarations.append(self.parse_declaration(tokens))
except ParseError as exc:
errors.append(exc)
# Skip the entire declaration
return declarations, errors
def parse_declaration(self, tokens):
"""Parse a single declaration.
:param tokens:
an iterable of at least one token. Should stop at (before)
the end of the declaration, as marked by a ``;`` or ``}``.
Empty declarations (ie. consecutive ``;`` with only white space
in-between) should be skipped earlier and not passed to
this method.
:returns:
a :class:`Declaration`
:raises:
:class:`~.parsing.ParseError` if the tokens do not match the
'declaration' production of the core grammar.
"""
tokens = iter(tokens)
name_token = next(tokens) # assume there is at least one
if name_token.type == 'IDENT':
# CSS syntax is case-insensitive
property_name = name_token.value.lower()
else:
raise ParseError(
name_token, 'expected a property name, got {0}'.format(
name_token.type))
token = name_token # In case ``tokens`` is now empty
for token in tokens:
if token.type == ':':
break
elif token.type != 'S':
raise ParseError(
token, "expected ':', got {0}".format(token.type))
else:
raise ParseError(token, "expected ':'")
value = strip_whitespace(list(tokens))
if not value:
raise ParseError(token, 'expected a property value')
validate_value(value)
value, priority = self.parse_value_priority(value)
return Declaration(
property_name, value, priority, name_token.line, name_token.column)
def parse_value_priority(self, tokens):
"""Separate any ``!important`` marker at the end of a property value.
:param tokens:
A list of tokens for the property value.
:returns:
A tuple of the actual property value (a list of tokens)
and the :attr:`~Declaration.priority`.
"""
value = list(tokens)
# Walk the token list from the end
token = value.pop()
if token.type == 'IDENT' and token.value.lower() == 'important':
while value:
token = value.pop()
if token.type == 'DELIM' and token.value == '!':
# Skip any white space before the '!'
while value and value[-1].type == 'S':
value.pop()
if not value:
raise ParseError(
token, 'expected a value before !important')
return value, 'important'
# Skip white space between '!' and 'important'
elif token.type != 'S':
break
return tokens, None

View File

@ -0,0 +1,252 @@
# coding: utf-8
"""
tinycss.decoding
----------------
Decoding stylesheets from bytes to Unicode.
http://www.w3.org/TR/CSS21/syndata.html#charset
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import operator
import re
from binascii import unhexlify
__all__ = ['decode'] # Everything else is implementation detail
def decode(css_bytes, protocol_encoding=None,
linking_encoding=None, document_encoding=None):
"""
Determine the character encoding from the passed metadata and the
``@charset`` rule in the stylesheet (if any); and decode accordingly.
If no encoding information is available or decoding fails,
decoding defaults to UTF-8 and then fall back on ISO-8859-1.
:param css_bytes:
a CSS stylesheet as a byte string
:param protocol_encoding:
The "charset" parameter of a "Content-Type" HTTP header (if any),
or similar metadata for other protocols.
:param linking_encoding:
``<link charset="">`` or other metadata from the linking mechanism
(if any)
:param document_encoding:
Encoding of the referring style sheet or document (if any)
:return:
A tuple of an Unicode string, with any BOM removed, and the
encoding that was used.
"""
if protocol_encoding:
css_unicode = try_encoding(css_bytes, protocol_encoding)
if css_unicode is not None:
return css_unicode, protocol_encoding
for encoding, pattern in ENCODING_MAGIC_NUMBERS:
match = pattern(css_bytes)
if match:
has_at_charset = isinstance(encoding, tuple)
if has_at_charset:
extract, endianness = encoding
encoding = extract(match.group(1))
# Get an ASCII-only unicode value.
# This is the only thing that works on both Python 2 and 3
# for bytes.decode()
# Non-ASCII encoding names are invalid anyway,
# but make sure they stay invalid.
encoding = encoding.decode('ascii', 'replace')
encoding = encoding.replace('\ufffd', '?')
if encoding.replace('-', '').replace('_', '').lower() in [
'utf16', 'utf32']:
encoding += endianness
encoding = encoding.encode('ascii', 'replace').decode('ascii')
css_unicode = try_encoding(css_bytes, encoding)
if css_unicode and not (has_at_charset and not
css_unicode.startswith('@charset "')):
return css_unicode, encoding
break
for encoding in [linking_encoding, document_encoding]:
if encoding:
css_unicode = try_encoding(css_bytes, encoding)
if css_unicode is not None:
return css_unicode, encoding
css_unicode = try_encoding(css_bytes, 'UTF-8')
if css_unicode is not None:
return css_unicode, 'UTF-8'
return try_encoding(css_bytes, 'ISO-8859-1', fallback=False), 'ISO-8859-1'
def try_encoding(css_bytes, encoding, fallback=True):
if fallback:
try:
css_unicode = css_bytes.decode(encoding)
# LookupError means unknown encoding
except (UnicodeDecodeError, LookupError):
return None
else:
css_unicode = css_bytes.decode(encoding)
if css_unicode and css_unicode[0] == '\ufeff':
# Remove any Byte Order Mark
css_unicode = css_unicode[1:]
return css_unicode
def hex2re(hex_data):
return re.escape(unhexlify(hex_data.replace(' ', '').encode('ascii')))
class Slicer(object):
"""Slice()[start:stop:end] == slice(start, stop, end)"""
def __getitem__(self, slice_):
return operator.itemgetter(slice_)
Slice = Slicer()
# List of (bom_size, encoding, pattern)
# bom_size is in bytes and can be zero
# encoding is a string or (slice_, endianness) for "as specified"
# slice_ is a slice object.How to extract the specified
ENCODING_MAGIC_NUMBERS = [
((Slice[:], ''), re.compile(
hex2re('EF BB BF 40 63 68 61 72 73 65 74 20 22') +
b'([^\x22]*?)' +
hex2re('22 3B')).match),
('UTF-8', re.compile(
hex2re('EF BB BF')).match),
((Slice[:], ''), re.compile(
hex2re('40 63 68 61 72 73 65 74 20 22') +
b'([^\x22]*?)' +
hex2re('22 3B')).match),
((Slice[1::2], '-BE'), re.compile(
hex2re('FE FF 00 40 00 63 00 68 00 61 00 72 00 73 00 65 00'
'74 00 20 00 22') +
b'((\x00[^\x22])*?)' +
hex2re('00 22 00 3B')).match),
((Slice[1::2], '-BE'), re.compile(
hex2re('00 40 00 63 00 68 00 61 00 72 00 73 00 65 00 74 00'
'20 00 22') +
b'((\x00[^\x22])*?)' +
hex2re('00 22 00 3B')).match),
((Slice[::2], '-LE'), re.compile(
hex2re('FF FE 40 00 63 00 68 00 61 00 72 00 73 00 65 00 74'
'00 20 00 22 00') +
b'(([^\x22]\x00)*?)' +
hex2re('22 00 3B 00')).match),
((Slice[::2], '-LE'), re.compile(
hex2re('40 00 63 00 68 00 61 00 72 00 73 00 65 00 74 00 20'
'00 22 00') +
b'(([^\x22]\x00)*?)' +
hex2re('22 00 3B 00')).match),
((Slice[3::4], '-BE'), re.compile(
hex2re('00 00 FE FF 00 00 00 40 00 00 00 63 00 00 00 68 00'
'00 00 61 00 00 00 72 00 00 00 73 00 00 00 65 00 00'
'00 74 00 00 00 20 00 00 00 22') +
b'((\x00\x00\x00[^\x22])*?)' +
hex2re('00 00 00 22 00 00 00 3B')).match),
((Slice[3::4], '-BE'), re.compile(
hex2re('00 00 00 40 00 00 00 63 00 00 00 68 00 00 00 61 00'
'00 00 72 00 00 00 73 00 00 00 65 00 00 00 74 00 00'
'00 20 00 00 00 22') +
b'((\x00\x00\x00[^\x22])*?)' +
hex2re('00 00 00 22 00 00 00 3B')).match),
# Python does not support 2143 or 3412 endianness, AFAIK.
# I guess we could fix it up ourselves but meh. Patches welcome.
# ((Slice[2::4], '-2143'), re.compile(
# hex2re('00 00 FF FE 00 00 40 00 00 00 63 00 00 00 68 00 00'
# '00 61 00 00 00 72 00 00 00 73 00 00 00 65 00 00 00'
# '74 00 00 00 20 00 00 00 22 00') +
# b'((\x00\x00[^\x22]\x00)*?)' +
# hex2re('00 00 22 00 00 00 3B 00')).match),
# ((Slice[2::4], '-2143'), re.compile(
# hex2re('00 00 40 00 00 00 63 00 00 00 68 00 00 00 61 00 00'
# '00 72 00 00 00 73 00 00 00 65 00 00 00 74 00 00 00'
# '20 00 00 00 22 00') +
# b'((\x00\x00[^\x22]\x00)*?)' +
# hex2re('00 00 22 00 00 00 3B 00')).match),
# ((Slice[1::4], '-3412'), re.compile(
# hex2re('FE FF 00 00 00 40 00 00 00 63 00 00 00 68 00 00 00'
# '61 00 00 00 72 00 00 00 73 00 00 00 65 00 00 00 74'
# '00 00 00 20 00 00 00 22 00 00') +
# b'((\x00[^\x22]\x00\x00)*?)' +
# hex2re('00 22 00 00 00 3B 00 00')).match),
# ((Slice[1::4], '-3412'), re.compile(
# hex2re('00 40 00 00 00 63 00 00 00 68 00 00 00 61 00 00 00'
# '72 00 00 00 73 00 00 00 65 00 00 00 74 00 00 00 20'
# '00 00 00 22 00 00') +
# b'((\x00[^\x22]\x00\x00)*?)' +
# hex2re('00 22 00 00 00 3B 00 00')).match),
((Slice[::4], '-LE'), re.compile(
hex2re('FF FE 00 00 40 00 00 00 63 00 00 00 68 00 00 00 61'
'00 00 00 72 00 00 00 73 00 00 00 65 00 00 00 74 00'
'00 00 20 00 00 00 22 00 00 00') +
b'(([^\x22]\x00\x00\x00)*?)' +
hex2re('22 00 00 00 3B 00 00 00')).match),
((Slice[::4], '-LE'), re.compile(
hex2re('40 00 00 00 63 00 00 00 68 00 00 00 61 00 00 00 72'
'00 00 00 73 00 00 00 65 00 00 00 74 00 00 00 20 00'
'00 00 22 00 00 00') +
b'(([^\x22]\x00\x00\x00)*?)' +
hex2re('22 00 00 00 3B 00 00 00')).match),
('UTF-32-BE', re.compile(
hex2re('00 00 FE FF')).match),
('UTF-32-LE', re.compile(
hex2re('FF FE 00 00')).match),
# ('UTF-32-2143', re.compile(
# hex2re('00 00 FF FE')).match),
# ('UTF-32-3412', re.compile(
# hex2re('FE FF 00 00')).match),
('UTF-16-BE', re.compile(
hex2re('FE FF')).match),
('UTF-16-LE', re.compile(
hex2re('FF FE')).match),
# Some of there are supported by Python, but I didnt bother.
# You know the story with patches ...
# # as specified, transcoded from EBCDIC to ASCII
# ('as_specified-EBCDIC', re.compile(
# hex2re('7C 83 88 81 99 A2 85 A3 40 7F')
# + b'([^\x7F]*?)'
# + hex2re('7F 5E')).match),
# # as specified, transcoded from IBM1026 to ASCII
# ('as_specified-IBM1026', re.compile(
# hex2re('AE 83 88 81 99 A2 85 A3 40 FC')
# + b'([^\xFC]*?)'
# + hex2re('FC 5E')).match),
# # as specified, transcoded from GSM 03.38 to ASCII
# ('as_specified-GSM_03.38', re.compile(
# hex2re('00 63 68 61 72 73 65 74 20 22')
# + b'([^\x22]*?)'
# + hex2re('22 3B')).match),
]

View File

@ -0,0 +1,162 @@
# coding: utf-8
"""
tinycss.page3
------------------
Support for CSS 3 Paged Media syntax:
http://dev.w3.org/csswg/css3-page/
Adds support for named page selectors and margin rules.
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import division, unicode_literals
from .css21 import CSS21Parser, ParseError
class MarginRule(object):
"""A parsed at-rule for margin box.
.. attribute:: at_keyword
One of the 16 following strings:
* ``@top-left-corner``
* ``@top-left``
* ``@top-center``
* ``@top-right``
* ``@top-right-corner``
* ``@bottom-left-corner``
* ``@bottom-left``
* ``@bottom-center``
* ``@bottom-right``
* ``@bottom-right-corner``
* ``@left-top``
* ``@left-middle``
* ``@left-bottom``
* ``@right-top``
* ``@right-middle``
* ``@right-bottom``
.. attribute:: declarations
A list of :class:`~.css21.Declaration` objects.
.. attribute:: line
Source line where this was read.
.. attribute:: column
Source column where this was read.
"""
def __init__(self, at_keyword, declarations, line, column):
self.at_keyword = at_keyword
self.declarations = declarations
self.line = line
self.column = column
class CSSPage3Parser(CSS21Parser):
"""Extend :class:`~.css21.CSS21Parser` for `CSS 3 Paged Media`_ syntax.
.. _CSS 3 Paged Media: http://dev.w3.org/csswg/css3-page/
Compared to CSS 2.1, the ``at_rules`` and ``selector`` attributes of
:class:`~.css21.PageRule` objects are modified:
* ``at_rules`` is not always empty, it is a list of :class:`MarginRule`
objects.
* ``selector``, instead of a single string, is a tuple of the page name
and the pseudo class. Each of these may be a ``None`` or a string.
+--------------------------+------------------------+
| CSS | Parsed selectors |
+==========================+========================+
| .. code-block:: css | .. code-block:: python |
| | |
| @page {} | (None, None) |
| @page :first {} | (None, 'first') |
| @page chapter {} | ('chapter', None) |
| @page table:right {} | ('table', 'right') |
+--------------------------+------------------------+
"""
PAGE_MARGIN_AT_KEYWORDS = [
'@top-left-corner',
'@top-left',
'@top-center',
'@top-right',
'@top-right-corner',
'@bottom-left-corner',
'@bottom-left',
'@bottom-center',
'@bottom-right',
'@bottom-right-corner',
'@left-top',
'@left-middle',
'@left-bottom',
'@right-top',
'@right-middle',
'@right-bottom',
]
def parse_at_rule(self, rule, previous_rules, errors, context):
if rule.at_keyword in self.PAGE_MARGIN_AT_KEYWORDS:
if context != '@page':
raise ParseError(
rule, '{0} rule not allowed in {1}'.format(
rule.at_keyword, context))
if rule.head:
raise ParseError(
rule.head[0],
'unexpected {0} token in {1} rule header'.format(
rule.head[0].type, rule.at_keyword))
declarations, body_errors = self.parse_declaration_list(rule.body)
errors.extend(body_errors)
return MarginRule(
rule.at_keyword, declarations, rule.line, rule.column)
return super(CSSPage3Parser, self).parse_at_rule(
rule, previous_rules, errors, context)
def parse_page_selector(self, head):
"""Parse an @page selector.
:param head:
The ``head`` attribute of an unparsed :class:`AtRule`.
:returns:
A page selector. For CSS 2.1, this is 'first', 'left', 'right'
or None. 'blank' is added by GCPM.
:raises:
:class`~parsing.ParseError` on invalid selectors
"""
if not head:
return (None, None), (0, 0, 0)
if head[0].type == 'IDENT':
name = head.pop(0).value
while head and head[0].type == 'S':
head.pop(0)
if not head:
return (name, None), (1, 0, 0)
name_specificity = (1,)
else:
name = None
name_specificity = (0,)
if (len(head) == 2 and head[0].type == ':' and
head[1].type == 'IDENT'):
pseudo_class = head[1].value
specificity = {
'first': (1, 0), 'blank': (1, 0),
'left': (0, 1), 'right': (0, 1),
}.get(pseudo_class)
if specificity:
return (name, pseudo_class), (name_specificity + specificity)
raise ParseError(head[0], 'invalid @page selector')

View File

@ -0,0 +1,166 @@
# coding: utf-8
"""
tinycss.parsing
---------------
Utilities for parsing lists of tokens.
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
# TODO: unit tests
def split_on_comma(tokens):
"""Split a list of tokens on commas, ie ``,`` DELIM tokens.
Only "top-level" comma tokens are splitting points, not commas inside a
function or other :class:`ContainerToken`.
:param tokens:
An iterable of :class:`~.token_data.Token` or
:class:`~.token_data.ContainerToken`.
:returns:
A list of lists of tokens
"""
parts = []
this_part = []
for token in tokens:
if token.type == 'DELIM' and token.value == ',':
parts.append(this_part)
this_part = []
else:
this_part.append(token)
parts.append(this_part)
return parts
def strip_whitespace(tokens):
"""Remove whitespace at the beggining and end of a token list.
Whitespace tokens in-between other tokens in the list are preserved.
:param tokens:
A list of :class:`~.token_data.Token` or
:class:`~.token_data.ContainerToken`.
:return:
A new sub-sequence of the list.
"""
for i, token in enumerate(tokens):
if token.type != 'S':
break
else:
return [] # only whitespace
tokens = tokens[i:]
while tokens and tokens[-1].type == 'S':
tokens.pop()
return tokens
def remove_whitespace(tokens):
"""Remove any top-level whitespace in a token list.
Whitespace tokens inside recursive :class:`~.token_data.ContainerToken`
are preserved.
:param tokens:
A list of :class:`~.token_data.Token` or
:class:`~.token_data.ContainerToken`.
:return:
A new sub-sequence of the list.
"""
return [token for token in tokens if token.type != 'S']
def validate_value(tokens):
"""Validate a property value.
:param tokens:
an iterable of tokens
:raises:
:class:`ParseError` if there is any invalid token for the 'value'
production of the core grammar.
"""
for token in tokens:
type_ = token.type
if type_ == '{':
validate_block(token.content, 'property value')
else:
validate_any(token, 'property value')
def validate_block(tokens, context):
"""
:raises:
:class:`ParseError` if there is any invalid token for the 'block'
production of the core grammar.
:param tokens: an iterable of tokens
:param context: a string for the 'unexpected in ...' message
"""
for token in tokens:
type_ = token.type
if type_ == '{':
validate_block(token.content, context)
elif type_ not in (';', 'ATKEYWORD'):
validate_any(token, context)
def validate_any(token, context):
"""
:raises:
:class:`ParseError` if this is an invalid token for the
'any' production of the core grammar.
:param token: a single token
:param context: a string for the 'unexpected in ...' message
"""
type_ = token.type
if type_ in ('FUNCTION', '(', '['):
for token in token.content:
validate_any(token, type_)
elif type_ not in ('S', 'IDENT', 'DIMENSION', 'PERCENTAGE', 'NUMBER',
'INTEGER', 'URI', 'DELIM', 'STRING', 'HASH', ':',
'UNICODE-RANGE'):
if type_ in ('}', ')', ']'):
adjective = 'unmatched'
else:
adjective = 'unexpected'
raise ParseError(
token, '{0} {1} token in {2}'.format(adjective, type_, context))
class ParseError(ValueError):
"""Details about a CSS syntax error. Usually indicates that something
(a rule or a declaration) was ignored and will not appear as a parsed
object.
This exception is typically logged in a list rather than being propagated
to the user API.
.. attribute:: line
Source line where the error occured.
.. attribute:: column
Column in the source line where the error occured.
.. attribute:: reason
What happend (a string).
"""
def __init__(self, subject, reason):
self.line = subject.line
self.column = subject.column
self.reason = reason
super(ParseError, self).__init__(
'Parse error at {0.line}:{0.column}, {0.reason}'.format(self))

View File

@ -0,0 +1,189 @@
# coding: utf-8
"""
tinycss.speedups
----------------
Cython module for speeding up inner loops.
Right now only :func:`tokenize_flat` has a second implementation.
:copyright: (c) 2010 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
from .token_data import (
COMPILED_TOKEN_REGEXPS, UNICODE_UNESCAPE, NEWLINE_UNESCAPE,
SIMPLE_UNESCAPE, FIND_NEWLINES, TOKEN_DISPATCH)
COMPILED_TOKEN_INDEXES = dict(
(name, i) for i, (name, regexp) in enumerate(COMPILED_TOKEN_REGEXPS))
cdef class CToken:
"""A token built by the Cython speedups. Identical to
:class:`~.token_data.Token`.
"""
is_container = False
cdef public object type, _as_css, value, unit
cdef public Py_ssize_t line, column
def __init__(self, type_, css_value, value, unit, line, column):
self.type = type_
self._as_css = css_value
self.value = value
self.unit = unit
self.line = line
self.column = column
def as_css(self):
"""
Return as an Unicode string the CSS representation of the token,
as parsed in the source.
"""
return self._as_css
def __repr__(self):
return ('<Token {0.type} at {0.line}:{0.column} {0.value!r}{1}>'
.format(self, self.unit or ''))
def tokenize_flat(css_source, int ignore_comments=1):
"""
:param css_source:
CSS as an unicode string
:param ignore_comments:
if true (the default) comments will not be included in the
return value
:return:
An iterator of :class:`Token`
"""
# Make these local variable to avoid global lookups in the loop
tokens_dispatch = TOKEN_DISPATCH
compiled_token_indexes = COMPILED_TOKEN_INDEXES
compiled_tokens = COMPILED_TOKEN_REGEXPS
unicode_unescape = UNICODE_UNESCAPE
newline_unescape = NEWLINE_UNESCAPE
simple_unescape = SIMPLE_UNESCAPE
find_newlines = FIND_NEWLINES
# Use the integer indexes instead of string markers
cdef Py_ssize_t BAD_COMMENT = compiled_token_indexes['BAD_COMMENT']
cdef Py_ssize_t BAD_STRING = compiled_token_indexes['BAD_STRING']
cdef Py_ssize_t PERCENTAGE = compiled_token_indexes['PERCENTAGE']
cdef Py_ssize_t DIMENSION = compiled_token_indexes['DIMENSION']
cdef Py_ssize_t ATKEYWORD = compiled_token_indexes['ATKEYWORD']
cdef Py_ssize_t FUNCTION = compiled_token_indexes['FUNCTION']
cdef Py_ssize_t COMMENT = compiled_token_indexes['COMMENT']
cdef Py_ssize_t NUMBER = compiled_token_indexes['NUMBER']
cdef Py_ssize_t STRING = compiled_token_indexes['STRING']
cdef Py_ssize_t IDENT = compiled_token_indexes['IDENT']
cdef Py_ssize_t HASH = compiled_token_indexes['HASH']
cdef Py_ssize_t URI = compiled_token_indexes['URI']
cdef Py_ssize_t DELIM = -1
cdef Py_ssize_t pos = 0
cdef Py_ssize_t line = 1
cdef Py_ssize_t column = 1
cdef Py_ssize_t source_len = len(css_source)
cdef Py_ssize_t n_tokens = len(compiled_tokens)
cdef Py_ssize_t length, next_pos, type_
cdef CToken token
tokens = []
while pos < source_len:
char = css_source[pos]
if char in ':;{}()[]':
type_ = -1 # not parsed further anyway
type_name = char
css_value = char
else:
codepoint = min(ord(char), 160)
for type_, type_name, regexp in tokens_dispatch[codepoint]:
match = regexp(css_source, pos)
if match:
# First match is the longest. See comments on TOKENS above.
css_value = match.group()
break
else:
# No match.
# "Any other character not matched by the above rules,
# and neither a single nor a double quote."
# ... but quotes at the start of a token are always matched
# by STRING or BAD_STRING. So DELIM is any single character.
type_ = DELIM
type_name = 'DELIM'
css_value = char
length = len(css_value)
next_pos = pos + length
# A BAD_COMMENT is a comment at EOF. Ignore it too.
if not (ignore_comments and type_ in (COMMENT, BAD_COMMENT)):
# Parse numbers, extract strings and URIs, unescape
unit = None
if type_ == DIMENSION:
value = match.group(1)
value = float(value) if '.' in value else int(value)
unit = match.group(2)
unit = simple_unescape(unit)
unit = unicode_unescape(unit)
unit = unit.lower() # normalize
elif type_ == PERCENTAGE:
value = css_value[:-1]
value = float(value) if '.' in value else int(value)
unit = '%'
elif type_ == NUMBER:
value = css_value
if '.' in value:
value = float(value)
else:
value = int(value)
type_name = 'INTEGER'
elif type_ in (IDENT, ATKEYWORD, HASH, FUNCTION):
value = simple_unescape(css_value)
value = unicode_unescape(value)
elif type_ == URI:
value = match.group(1)
if value and value[0] in '"\'':
value = value[1:-1] # Remove quotes
value = newline_unescape(value)
value = simple_unescape(value)
value = unicode_unescape(value)
elif type_ == STRING:
value = css_value[1:-1] # Remove quotes
value = newline_unescape(value)
value = simple_unescape(value)
value = unicode_unescape(value)
# BAD_STRING can only be one of:
# * Unclosed string at the end of the stylesheet:
# Close the string, but this is not an error.
# Make it a "good" STRING token.
# * Unclosed string at the (unescaped) end of the line:
# Close the string, but this is an error.
# Leave it as a BAD_STRING, dont bother parsing it.
# See http://www.w3.org/TR/CSS21/syndata.html#parsing-errors
elif type_ == BAD_STRING and next_pos == source_len:
type_name = 'STRING'
value = css_value[1:] # Remove quote
value = newline_unescape(value)
value = simple_unescape(value)
value = unicode_unescape(value)
else:
value = css_value
token = CToken(type_name, css_value, value, unit, line, column)
tokens.append(token)
pos = next_pos
newlines = list(find_newlines(css_value))
if newlines:
line += len(newlines)
# Add 1 to have lines start at column 1, not 0
column = length - newlines[-1].end() + 1
else:
column += length
return tokens

View File

@ -0,0 +1,26 @@
# coding: utf-8
"""
Test suite for tinycss
----------------------
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import sys
# Awful workaround to fix isort's "sys.setdefaultencoding('utf-8')".
if sys.version_info[0] == 2:
reload(sys) # noqa
sys.setdefaultencoding('ascii')
def assert_errors(errors, expected_errors):
"""Test not complete error messages but only substrings."""
assert len(errors) == len(expected_errors)
for error, expected in zip(errors, expected_errors):
assert expected in str(error)

View File

@ -0,0 +1,134 @@
# coding: utf-8
"""
Speed tests
-----------
Note: this file is not named test_*.py as it is not part of the
test suite ran by pytest.
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import division, unicode_literals
import contextlib
import functools
import os.path
import sys
import timeit
from cssutils import parseString
from .. import tokenizer
from ..css21 import CSS21Parser
from ..parsing import remove_whitespace
CSS_REPEAT = 4
TIMEIT_REPEAT = 3
TIMEIT_NUMBER = 20
def load_css():
filename = os.path.join(os.path.dirname(__file__),
'..', '..', 'docs', '_static', 'custom.css')
with open(filename, 'rb') as fd:
return b'\n'.join([fd.read()] * CSS_REPEAT)
# Pre-load so that I/O is not measured
CSS = load_css()
@contextlib.contextmanager
def install_tokenizer(name):
original = tokenizer.tokenize_flat
try:
tokenizer.tokenize_flat = getattr(tokenizer, name)
yield
finally:
tokenizer.tokenize_flat = original
def parse(tokenizer_name):
with install_tokenizer(tokenizer_name):
stylesheet = CSS21Parser().parse_stylesheet_bytes(CSS)
result = []
for rule in stylesheet.rules:
selector = rule.selector.as_css()
declarations = [
(declaration.name, len(list(remove_whitespace(declaration.value))))
for declaration in rule.declarations]
result.append((selector, declarations))
return result
parse_cython = functools.partial(parse, 'cython_tokenize_flat')
parse_python = functools.partial(parse, 'python_tokenize_flat')
def parse_cssutils():
stylesheet = parseString(CSS)
result = []
for rule in stylesheet.cssRules:
selector = rule.selectorText
declarations = [
(declaration.name, len(list(declaration.propertyValue)))
for declaration in rule.style.getProperties(all=True)]
result.append((selector, declarations))
return result
def check_consistency():
result = parse_python()
assert len(result) > 0
if tokenizer.cython_tokenize_flat:
assert parse_cython() == result
assert parse_cssutils() == result
version = '.'.join(map(str, sys.version_info[:3]))
print('Python {}, consistency OK.'.format(version))
def warm_up():
is_pypy = hasattr(sys, 'pypy_translation_info')
if is_pypy:
print('Warming up for PyPy...')
for i in range(80):
for i in range(10):
parse_python()
parse_cssutils()
sys.stdout.write('.')
sys.stdout.flush()
sys.stdout.write('\n')
def time(function):
seconds = timeit.Timer(function).repeat(TIMEIT_REPEAT, TIMEIT_NUMBER)
miliseconds = int(min(seconds) * 1000)
return miliseconds
def run():
if tokenizer.cython_tokenize_flat:
data_set = [
('tinycss + speedups ', parse_cython),
]
else:
print('Speedups are NOT available.')
data_set = []
data_set += [
('tinycss WITHOUT speedups', parse_python),
('cssutils ', parse_cssutils),
]
label, function = data_set.pop(0)
ref = time(function)
print('{} {} ms'.format(label, ref))
for label, function in data_set:
result = time(function)
print('{} {} ms {:.2f}x'.format(label, result, result / ref))
if __name__ == '__main__':
check_consistency()
warm_up()
run()

View File

@ -0,0 +1,45 @@
# coding: utf-8
"""
Tests for the public API
------------------------
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
from pytest import raises
from tinycss import make_parser
from tinycss.page3 import CSSPage3Parser
def test_make_parser():
class MyParser(object):
def __init__(self, some_config):
self.some_config = some_config
parsers = [
make_parser(),
make_parser('page3'),
make_parser(CSSPage3Parser),
make_parser(MyParser, some_config=42),
make_parser(CSSPage3Parser, MyParser, some_config=42),
make_parser(MyParser, 'page3', some_config=42),
]
for parser, exp in zip(parsers, [False, True, True, False, True, True]):
assert isinstance(parser, CSSPage3Parser) == exp
for parser, exp in zip(parsers, [False, False, False, True, True, True]):
assert isinstance(parser, MyParser) == exp
for parser in parsers[3:]:
assert parser.some_config == 42
# Extra or missing named parameters
raises(TypeError, make_parser, some_config=4)
raises(TypeError, make_parser, 'page3', some_config=4)
raises(TypeError, make_parser, MyParser)
raises(TypeError, make_parser, MyParser, some_config=4, other_config=7)

View File

@ -0,0 +1,202 @@
# coding: utf-8
"""
Tests for the CSS 3 color parser
--------------------------------
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import pytest
from tinycss.color3 import hsl_to_rgb, parse_color_string
@pytest.mark.parametrize(('css_source', 'expected_result'), [
('', None),
(' /* hey */\n', None),
('4', None),
('top', None),
('/**/transparent', (0, 0, 0, 0)),
('transparent', (0, 0, 0, 0)),
(' transparent\n', (0, 0, 0, 0)),
('TransParent', (0, 0, 0, 0)),
('currentColor', 'currentColor'),
('CURRENTcolor', 'currentColor'),
('current_Color', None),
('black', (0, 0, 0, 1)),
('white', (1, 1, 1, 1)),
('fuchsia', (1, 0, 1, 1)),
('cyan', (0, 1, 1, 1)),
('CyAn', (0, 1, 1, 1)),
('darkkhaki', (189 / 255., 183 / 255., 107 / 255., 1)),
('#', None),
('#f', None),
('#ff', None),
('#fff', (1, 1, 1, 1)),
('#ffg', None),
('#ffff', None),
('#fffff', None),
('#ffffff', (1, 1, 1, 1)),
('#fffffg', None),
('#fffffff', None),
('#ffffffff', None),
('#fffffffff', None),
('#cba987', (203 / 255., 169 / 255., 135 / 255., 1)),
('#CbA987', (203 / 255., 169 / 255., 135 / 255., 1)),
('#1122aA', (17 / 255., 34 / 255., 170 / 255., 1)),
('#12a', (17 / 255., 34 / 255., 170 / 255., 1)),
('rgb(203, 169, 135)', (203 / 255., 169 / 255., 135 / 255., 1)),
('RGB(255, 255, 255)', (1, 1, 1, 1)),
('rgB(0, 0, 0)', (0, 0, 0, 1)),
('rgB(0, 51, 255)', (0, .2, 1, 1)),
('rgb(0,51,255)', (0, .2, 1, 1)),
('rgb(0\t, 51 ,255)', (0, .2, 1, 1)),
('rgb(/* R */0, /* G */51, /* B */255)', (0, .2, 1, 1)),
('rgb(-51, 306, 0)', (-.2, 1.2, 0, 1)), # out of 0..1 is allowed
('rgb(42%, 3%, 50%)', (.42, .03, .5, 1)),
('RGB(100%, 100%, 100%)', (1, 1, 1, 1)),
('rgB(0%, 0%, 0%)', (0, 0, 0, 1)),
('rgB(10%, 20%, 30%)', (.1, .2, .3, 1)),
('rgb(10%,20%,30%)', (.1, .2, .3, 1)),
('rgb(10%\t, 20% ,30%)', (.1, .2, .3, 1)),
('rgb(/* R */10%, /* G */20%, /* B */30%)', (.1, .2, .3, 1)),
('rgb(-12%, 110%, 1400%)', (-.12, 1.1, 14, 1)), # out of 0..1 is allowed
('rgb(10%, 50%, 0)', None),
('rgb(255, 50%, 0%)', None),
('rgb(0, 0 0)', None),
('rgb(0, 0, 0deg)', None),
('rgb(0, 0, light)', None),
('rgb()', None),
('rgb(0)', None),
('rgb(0, 0)', None),
('rgb(0, 0, 0, 0)', None),
('rgb(0%)', None),
('rgb(0%, 0%)', None),
('rgb(0%, 0%, 0%, 0%)', None),
('rgb(0%, 0%, 0%, 0)', None),
('rgba(0, 0, 0, 0)', (0, 0, 0, 0)),
('rgba(203, 169, 135, 0.3)', (203 / 255., 169 / 255., 135 / 255., 0.3)),
('RGBA(255, 255, 255, 0)', (1, 1, 1, 0)),
('rgBA(0, 51, 255, 1)', (0, 0.2, 1, 1)),
('rgba(0, 51, 255, 1.1)', (0, 0.2, 1, 1)),
('rgba(0, 51, 255, 37)', (0, 0.2, 1, 1)),
('rgba(0, 51, 255, 0.42)', (0, 0.2, 1, 0.42)),
('rgba(0, 51, 255, 0)', (0, 0.2, 1, 0)),
('rgba(0, 51, 255, -0.1)', (0, 0.2, 1, 0)),
('rgba(0, 51, 255, -139)', (0, 0.2, 1, 0)),
('rgba(42%, 3%, 50%, 0.3)', (.42, .03, .5, 0.3)),
('RGBA(100%, 100%, 100%, 0)', (1, 1, 1, 0)),
('rgBA(0%, 20%, 100%, 1)', (0, 0.2, 1, 1)),
('rgba(0%, 20%, 100%, 1.1)', (0, 0.2, 1, 1)),
('rgba(0%, 20%, 100%, 37)', (0, 0.2, 1, 1)),
('rgba(0%, 20%, 100%, 0.42)', (0, 0.2, 1, 0.42)),
('rgba(0%, 20%, 100%, 0)', (0, 0.2, 1, 0)),
('rgba(0%, 20%, 100%, -0.1)', (0, 0.2, 1, 0)),
('rgba(0%, 20%, 100%, -139)', (0, 0.2, 1, 0)),
('rgba(255, 255, 255, 0%)', None),
('rgba(10%, 50%, 0, 1)', None),
('rgba(255, 50%, 0%, 1)', None),
('rgba(0, 0, 0 0)', None),
('rgba(0, 0, 0, 0deg)', None),
('rgba(0, 0, 0, light)', None),
('rgba()', None),
('rgba(0)', None),
('rgba(0, 0, 0)', None),
('rgba(0, 0, 0, 0, 0)', None),
('rgba(0%)', None),
('rgba(0%, 0%)', None),
('rgba(0%, 0%, 0%)', None),
('rgba(0%, 0%, 0%, 0%)', None),
('rgba(0%, 0%, 0%, 0%, 0%)', None),
('HSL(0, 0%, 0%)', (0, 0, 0, 1)),
('hsL(0, 100%, 50%)', (1, 0, 0, 1)),
('hsl(60, 100%, 37.5%)', (0.75, 0.75, 0, 1)),
('hsl(780, 100%, 37.5%)', (0.75, 0.75, 0, 1)),
('hsl(-300, 100%, 37.5%)', (0.75, 0.75, 0, 1)),
('hsl(300, 50%, 50%)', (0.75, 0.25, 0.75, 1)),
('hsl(10, 50%, 0)', None),
('hsl(50%, 50%, 0%)', None),
('hsl(0, 0% 0%)', None),
('hsl(30deg, 100%, 100%)', None),
('hsl(0, 0%, light)', None),
('hsl()', None),
('hsl(0)', None),
('hsl(0, 0%)', None),
('hsl(0, 0%, 0%, 0%)', None),
('HSLA(-300, 100%, 37.5%, 1)', (0.75, 0.75, 0, 1)),
('hsLA(-300, 100%, 37.5%, 12)', (0.75, 0.75, 0, 1)),
('hsla(-300, 100%, 37.5%, 0.2)', (0.75, 0.75, 0, .2)),
('hsla(-300, 100%, 37.5%, 0)', (0.75, 0.75, 0, 0)),
('hsla(-300, 100%, 37.5%, -3)', (0.75, 0.75, 0, 0)),
('hsla(10, 50%, 0, 1)', None),
('hsla(50%, 50%, 0%, 1)', None),
('hsla(0, 0% 0%, 1)', None),
('hsla(30deg, 100%, 100%, 1)', None),
('hsla(0, 0%, light, 1)', None),
('hsla()', None),
('hsla(0)', None),
('hsla(0, 0%)', None),
('hsla(0, 0%, 0%, 50%)', None),
('hsla(0, 0%, 0%, 1, 0%)', None),
('cmyk(0, 0, 0, 0)', None),
])
def test_color(css_source, expected_result):
result = parse_color_string(css_source)
if isinstance(result, tuple):
for got, expected in zip(result, expected_result):
# Compensate for floating point errors:
assert abs(got - expected) < 1e-10
for i, attr in enumerate(['red', 'green', 'blue', 'alpha']):
assert getattr(result, attr) == result[i]
else:
assert result == expected_result
@pytest.mark.parametrize(('hsl', 'expected_rgb'), [
# http://en.wikipedia.org/wiki/HSL_and_HSV#Examples
((0, 0, 100 ), (1, 1, 1 )), # noqa
((127, 0, 100 ), (1, 1, 1 )), # noqa
((0, 0, 50 ), (0.5, 0.5, 0.5 )), # noqa
((127, 0, 50 ), (0.5, 0.5, 0.5 )), # noqa
((0, 0, 0 ), (0, 0, 0 )), # noqa
((127, 0, 0 ), (0, 0, 0 )), # noqa
((0, 100, 50 ), (1, 0, 0 )), # noqa
((60, 100, 37.5), (0.75, 0.75, 0 )), # noqa
((780, 100, 37.5), (0.75, 0.75, 0 )), # noqa
((-300, 100, 37.5), (0.75, 0.75, 0 )), # noqa
((120, 100, 25 ), (0, 0.5, 0 )), # noqa
((180, 100, 75 ), (0.5, 1, 1 )), # noqa
((240, 100, 75 ), (0.5, 0.5, 1 )), # noqa
((300, 50, 50 ), (0.75, 0.25, 0.75 )), # noqa
((61.8, 63.8, 39.3), (0.628, 0.643, 0.142)), # noqa
((251.1, 83.2, 51.1), (0.255, 0.104, 0.918)), # noqa
((134.9, 70.7, 39.6), (0.116, 0.675, 0.255)), # noqa
((49.5, 89.3, 49.7), (0.941, 0.785, 0.053)), # noqa
((283.7, 77.5, 54.2), (0.704, 0.187, 0.897)), # noqa
((14.3, 81.7, 62.4), (0.931, 0.463, 0.316)), # noqa
((56.9, 99.1, 76.5), (0.998, 0.974, 0.532)), # noqa
((162.4, 77.9, 44.7), (0.099, 0.795, 0.591)), # noqa
((248.3, 60.1, 37.3), (0.211, 0.149, 0.597)), # noqa
((240.5, 29, 60.7), (0.495, 0.493, 0.721)), # noqa
])
def test_hsl(hsl, expected_rgb):
for got, expected in zip(hsl_to_rgb(*hsl), expected_rgb):
# Compensate for floating point errors and Wikipedias rounding:
assert abs(got - expected) < 0.001

View File

@ -0,0 +1,353 @@
# coding: utf-8
"""
Tests for the CSS 2.1 parser
----------------------------
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import io
import os
import tempfile
import pytest
from tinycss.css21 import CSS21Parser
from . import assert_errors
from .test_tokenizer import jsonify
def parse_bytes(css_bytes, kwargs):
return CSS21Parser().parse_stylesheet_bytes(css_bytes, **kwargs)
def parse_bytesio_file(css_bytes, kwargs):
css_file = io.BytesIO(css_bytes)
return CSS21Parser().parse_stylesheet_file(css_file, **kwargs)
def parse_filename(css_bytes, kwargs):
css_file = tempfile.NamedTemporaryFile(delete=False)
try:
css_file.write(css_bytes)
# Windows can not open the filename a second time while
# it is still open for writing.
css_file.close()
return CSS21Parser().parse_stylesheet_file(css_file.name, **kwargs)
finally:
os.remove(css_file.name)
@pytest.mark.parametrize(('css_bytes', 'kwargs', 'expected_result', 'parse'), [
params + (parse,)
for parse in [parse_bytes, parse_bytesio_file, parse_filename]
for params in [
('@import "é";'.encode('utf8'), {}, 'é'),
('@import "é";'.encode('utf16'), {}, 'é'), # with a BOM
('@import "é";'.encode('latin1'), {}, 'é'),
('@import "£";'.encode('Shift-JIS'), {}, '\x81\x92'), # lat1 mojibake
('@charset "Shift-JIS";@import "£";'.encode('Shift-JIS'), {}, '£'),
(' @charset "Shift-JIS";@import "£";'.encode('Shift-JIS'), {},
'\x81\x92'),
('@import "£";'.encode('Shift-JIS'),
{'document_encoding': 'Shift-JIS'}, '£'),
('@import "£";'.encode('Shift-JIS'),
{'document_encoding': 'utf8'}, '\x81\x92'),
('@charset "utf8"; @import "£";'.encode('utf8'),
{'document_encoding': 'latin1'}, '£'),
# Mojibake yay!
(' @charset "utf8"; @import "é";'.encode('utf8'),
{'document_encoding': 'latin1'}, 'é'),
('@import "é";'.encode('utf8'), {'document_encoding': 'latin1'}, 'é'),
]
])
def test_bytes(css_bytes, kwargs, expected_result, parse):
stylesheet = parse(css_bytes, kwargs)
assert stylesheet.rules[0].at_keyword == '@import'
assert stylesheet.rules[0].uri == expected_result
@pytest.mark.parametrize(('css_source', 'expected_rules', 'expected_errors'), [
(' /* hey */\n', 0, []),
('foo {}', 1, []),
('foo{} @lipsum{} bar{}', 2,
['unknown at-rule in stylesheet context: @lipsum']),
('@charset "ascii"; foo {}', 1, []),
(' @charset "ascii"; foo {}', 1, [
'mis-placed or malformed @charset rule']),
('@charset ascii; foo {}', 1, ['mis-placed or malformed @charset rule']),
('foo {} @charset "ascii";', 1, ['mis-placed or malformed @charset rule']),
])
def test_at_rules(css_source, expected_rules, expected_errors):
# Pass 'encoding' to allow @charset
stylesheet = CSS21Parser().parse_stylesheet(css_source, encoding='utf8')
assert_errors(stylesheet.errors, expected_errors)
result = len(stylesheet.rules)
assert result == expected_rules
@pytest.mark.parametrize(('css_source', 'expected_rules', 'expected_errors'), [
(' /* hey */\n', [], []),
('foo{} /* hey */\n@bar;@baz{}',
[('foo', []), ('@bar', [], None), ('@baz', [], [])], []),
('@import "foo.css"/**/;', [
('@import', [('STRING', 'foo.css')], None)], []),
('@import "foo.css"/**/', [
('@import', [('STRING', 'foo.css')], None)], []),
('@import "foo.css', [
('@import', [('STRING', 'foo.css')], None)], []),
('{}', [], ['empty selector']),
('a{b:4}', [('a', [('b', [('INTEGER', 4)])])], []),
('@page {\t b: 4; @margin}', [('@page', [], [
('S', '\t '), ('IDENT', 'b'), (':', ':'), ('S', ' '), ('INTEGER', 4),
(';', ';'), ('S', ' '), ('ATKEYWORD', '@margin'),
])], []),
('foo', [], ['no declaration block found']),
('foo @page {} bar {}', [('bar', [])],
['unexpected ATKEYWORD token in selector']),
('foo { content: "unclosed string;\n color:red; ; margin/**/\n: 2cm; }',
[('foo', [('margin', [('DIMENSION', 2)])])],
['unexpected BAD_STRING token in property value']),
('foo { 4px; bar: 12% }',
[('foo', [('bar', [('PERCENTAGE', 12)])])],
['expected a property name, got DIMENSION']),
('foo { bar! 3cm auto ; baz: 7px }',
[('foo', [('baz', [('DIMENSION', 7)])])],
["expected ':', got DELIM"]),
('foo { bar ; baz: {("}"/* comment */) {0@fizz}} }',
[('foo', [('baz', [('{', [
('(', [('STRING', '}')]), ('S', ' '),
('{', [('INTEGER', 0), ('ATKEYWORD', '@fizz')])
])])])],
["expected ':'"]),
('foo { bar: ; baz: not(z) }',
[('foo', [('baz', [('FUNCTION', 'not', [('IDENT', 'z')])])])],
['expected a property value']),
('foo { bar: (]) ; baz: U+20 }',
[('foo', [('baz', [('UNICODE-RANGE', 'U+20')])])],
['unmatched ] token in (']),
])
def test_core_parser(css_source, expected_rules, expected_errors):
class CoreParser(CSS21Parser):
"""A parser that always accepts unparsed at-rules."""
def parse_at_rule(self, rule, stylesheet_rules, errors, context):
return rule
stylesheet = CoreParser().parse_stylesheet(css_source)
assert_errors(stylesheet.errors, expected_errors)
result = [
(rule.at_keyword, list(jsonify(rule.head)),
list(jsonify(rule.body))
if rule.body is not None else None)
if rule.at_keyword else
(rule.selector.as_css(), [
(decl.name, list(jsonify(decl.value)))
for decl in rule.declarations])
for rule in stylesheet.rules
]
assert result == expected_rules
@pytest.mark.parametrize(('css_source', 'expected_declarations',
'expected_errors'), [
(' /* hey */\n', [], []),
('b:4', [('b', [('INTEGER', 4)])], []),
('{b:4}', [], ['expected a property name, got {']),
('b:4} c:3', [], ['unmatched } token in property value']),
(' 4px; bar: 12% ',
[('bar', [('PERCENTAGE', 12)])],
['expected a property name, got DIMENSION']),
('bar! 3cm auto ; baz: 7px',
[('baz', [('DIMENSION', 7)])],
["expected ':', got DELIM"]),
('foo; bar ; baz: {("}"/* comment */) {0@fizz}}',
[('baz', [('{', [
('(', [('STRING', '}')]), ('S', ' '),
('{', [('INTEGER', 0), ('ATKEYWORD', '@fizz')])
])])],
["expected ':'", "expected ':'"]),
('bar: ; baz: not(z)',
[('baz', [('FUNCTION', 'not', [('IDENT', 'z')])])],
['expected a property value']),
('bar: (]) ; baz: U+20',
[('baz', [('UNICODE-RANGE', 'U+20')])],
['unmatched ] token in (']),
])
def test_parse_style_attr(css_source, expected_declarations, expected_errors):
declarations, errors = CSS21Parser().parse_style_attr(css_source)
assert_errors(errors, expected_errors)
result = [(decl.name, list(jsonify(decl.value)))
for decl in declarations]
assert result == expected_declarations
@pytest.mark.parametrize(('css_source', 'expected_declarations',
'expected_errors'), [
(' /* hey */\n', [], []),
('a:1; b:2',
[('a', [('INTEGER', 1)], None), ('b', [('INTEGER', 2)], None)], []),
('a:1 important; b: important',
[('a', [('INTEGER', 1), ('S', ' '), ('IDENT', 'important')], None),
('b', [('IDENT', 'important')], None)],
[]),
('a:1 !important; b:2',
[('a', [('INTEGER', 1)], 'important'), ('b', [('INTEGER', 2)], None)],
[]),
('a:1!\t Im\\50 O\\RTant; b:2',
[('a', [('INTEGER', 1)], 'important'), ('b', [('INTEGER', 2)], None)],
[]),
('a: !important; b:2',
[('b', [('INTEGER', 2)], None)],
['expected a value before !important']),
])
def test_important(css_source, expected_declarations, expected_errors):
declarations, errors = CSS21Parser().parse_style_attr(css_source)
assert_errors(errors, expected_errors)
result = [(decl.name, list(jsonify(decl.value)), decl.priority)
for decl in declarations]
assert result == expected_declarations
@pytest.mark.parametrize(('css_source', 'expected_rules', 'expected_errors'), [
(' /* hey */\n', [], []),
('@import "foo.css";', [('foo.css', ['all'])], []),
('@import url(foo.css);', [('foo.css', ['all'])], []),
('@import "foo.css" screen, print;',
[('foo.css', ['screen', 'print'])], []),
('@charset "ascii"; @import "foo.css"; @import "bar.css";',
[('foo.css', ['all']), ('bar.css', ['all'])], []),
('foo {} @import "foo.css";',
[], ['@import rule not allowed after a ruleset']),
('@page {} @import "foo.css";',
[], ['@import rule not allowed after an @page rule']),
('@import ;',
[], ['expected URI or STRING for @import rule']),
('@import foo.css;',
[], ['expected URI or STRING for @import rule, got IDENT']),
('@import "foo.css" {}',
[], ["expected ';', got a block"]),
])
def test_at_import(css_source, expected_rules, expected_errors):
# Pass 'encoding' to allow @charset
stylesheet = CSS21Parser().parse_stylesheet(css_source, encoding='utf8')
assert_errors(stylesheet.errors, expected_errors)
result = [
(rule.uri, rule.media)
for rule in stylesheet.rules
if rule.at_keyword == '@import'
]
assert result == expected_rules
@pytest.mark.parametrize(('css', 'expected_result', 'expected_errors'), [
('@page {}', (None, (0, 0), []), []),
('@page:first {}', ('first', (1, 0), []), []),
('@page :left{}', ('left', (0, 1), []), []),
('@page\t\n:right {}', ('right', (0, 1), []), []),
('@page :last {}', None, ['invalid @page selector']),
('@page : right {}', None, ['invalid @page selector']),
('@page table:left {}', None, ['invalid @page selector']),
('@page;', None, ['invalid @page rule: missing block']),
('@page { a:1; ; b: 2 }',
(None, (0, 0), [('a', [('INTEGER', 1)]), ('b', [('INTEGER', 2)])]),
[]),
('@page { a:1; c: ; b: 2 }',
(None, (0, 0), [('a', [('INTEGER', 1)]), ('b', [('INTEGER', 2)])]),
['expected a property value']),
('@page { a:1; @top-left {} b: 2 }',
(None, (0, 0), [('a', [('INTEGER', 1)]), ('b', [('INTEGER', 2)])]),
['unknown at-rule in @page context: @top-left']),
('@page { a:1; @top-left {}; b: 2 }',
(None, (0, 0), [('a', [('INTEGER', 1)]), ('b', [('INTEGER', 2)])]),
['unknown at-rule in @page context: @top-left']),
])
def test_at_page(css, expected_result, expected_errors):
stylesheet = CSS21Parser().parse_stylesheet(css)
assert_errors(stylesheet.errors, expected_errors)
if expected_result is None:
assert not stylesheet.rules
else:
assert len(stylesheet.rules) == 1
rule = stylesheet.rules[0]
assert rule.at_keyword == '@page'
assert rule.at_rules == [] # in CSS 2.1
result = (
rule.selector,
rule.specificity,
[(decl.name, list(jsonify(decl.value)))
for decl in rule.declarations],
)
assert result == expected_result
@pytest.mark.parametrize(('css_source', 'expected_rules', 'expected_errors'), [
(' /* hey */\n', [], []),
('@media all {}', [(['all'], [])], []),
('@media screen, print {}', [(['screen', 'print'], [])], []),
('@media all;', [], ['invalid @media rule: missing block']),
('@media {}', [], ['expected media types for @media']),
('@media 4 {}', [], ['expected a media type, got INTEGER']),
('@media , screen {}', [], ['expected a media type']),
('@media screen, {}', [], ['expected a media type']),
('@media screen print {}', [],
['expected a media type, got IDENT, IDENT']),
('@media all { @page { a: 1 } @media; @import; foo { a: 1 } }',
[(['all'], [('foo', [('a', [('INTEGER', 1)])])])],
['@page rule not allowed in @media',
'@media rule not allowed in @media',
'@import rule not allowed in @media']),
])
def test_at_media(css_source, expected_rules, expected_errors):
stylesheet = CSS21Parser().parse_stylesheet(css_source)
assert_errors(stylesheet.errors, expected_errors)
for rule in stylesheet.rules:
assert rule.at_keyword == '@media'
result = [
(rule.media, [
(sub_rule.selector.as_css(), [
(decl.name, list(jsonify(decl.value)))
for decl in sub_rule.declarations])
for sub_rule in rule.rules
])
for rule in stylesheet.rules
]
assert result == expected_rules

View File

@ -0,0 +1,79 @@
# coding: utf-8
"""
Tests for decoding bytes to Unicode
-----------------------------------
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import pytest
from tinycss.decoding import decode
def params(css, encoding, use_bom=False, expect_error=False, **kwargs):
"""Nicer syntax to make a tuple."""
return css, encoding, use_bom, expect_error, kwargs
@pytest.mark.parametrize(('css', 'encoding', 'use_bom', 'expect_error',
'kwargs'), [
params('', 'utf8'), # default to utf8
params('𐂃', 'utf8'),
params('é', 'latin1'), # utf8 fails, fall back on ShiftJIS
params('£', 'ShiftJIS', expect_error=True),
params('£', 'ShiftJIS', protocol_encoding='Shift-JIS'),
params('£', 'ShiftJIS', linking_encoding='Shift-JIS'),
params('£', 'ShiftJIS', document_encoding='Shift-JIS'),
params('£', 'ShiftJIS', protocol_encoding='utf8',
document_encoding='ShiftJIS'),
params('@charset "utf8"; £', 'ShiftJIS', expect_error=True),
params('@charset "utf£8"; £', 'ShiftJIS', expect_error=True),
params('@charset "unknown-encoding"; £', 'ShiftJIS', expect_error=True),
params('@charset "utf8"; £', 'ShiftJIS', document_encoding='ShiftJIS'),
params('£', 'ShiftJIS', linking_encoding='utf8',
document_encoding='ShiftJIS'),
params('@charset "utf-32"; 𐂃', 'utf-32-be'),
params('@charset "Shift-JIS"; £', 'ShiftJIS'),
params('@charset "ISO-8859-8"; £', 'ShiftJIS', expect_error=True),
params('𐂃', 'utf-16-le', expect_error=True), # no BOM
params('𐂃', 'utf-16-le', use_bom=True),
params('𐂃', 'utf-32-be', expect_error=True),
params('𐂃', 'utf-32-be', use_bom=True),
params('𐂃', 'utf-32-be', document_encoding='utf-32-be'),
params('𐂃', 'utf-32-be', linking_encoding='utf-32-be'),
params('@charset "utf-32-le"; 𐂃', 'utf-32-be',
use_bom=True, expect_error=True),
# protocol_encoding takes precedence over @charset
params('@charset "ISO-8859-8"; £', 'ShiftJIS',
protocol_encoding='Shift-JIS'),
params('@charset "unknown-encoding"; £', 'ShiftJIS',
protocol_encoding='Shift-JIS'),
params('@charset "Shift-JIS"; £', 'ShiftJIS',
protocol_encoding='utf8'),
# @charset takes precedence over document_encoding
params('@charset "Shift-JIS"; £', 'ShiftJIS',
document_encoding='ISO-8859-8'),
# @charset takes precedence over linking_encoding
params('@charset "Shift-JIS"; £', 'ShiftJIS',
linking_encoding='ISO-8859-8'),
# linking_encoding takes precedence over document_encoding
params('£', 'ShiftJIS',
linking_encoding='Shift-JIS', document_encoding='ISO-8859-8'),
])
def test_decode(css, encoding, use_bom, expect_error, kwargs):
# Workaround PyPy and CPython 3.0 bug: https://bugs.pypy.org/issue1094
css = css.encode('utf16').decode('utf16')
if use_bom:
source = '\ufeff' + css
else:
source = css
css_bytes = source.encode(encoding)
result, result_encoding = decode(css_bytes, **kwargs)
if expect_error:
assert result != css, 'Unexpected unicode success'
else:
assert result == css, 'Unexpected unicode error'

View File

@ -0,0 +1,101 @@
# coding: utf-8
"""
Tests for the Paged Media 3 parser
----------------------------------
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import pytest
from tinycss.page3 import CSSPage3Parser
from . import assert_errors
from .test_tokenizer import jsonify
@pytest.mark.parametrize(('css', 'expected_selector',
'expected_specificity', 'expected_errors'), [
('@page {}', (None, None), (0, 0, 0), []),
('@page :first {}', (None, 'first'), (0, 1, 0), []),
('@page:left{}', (None, 'left'), (0, 0, 1), []),
('@page :right {}', (None, 'right'), (0, 0, 1), []),
('@page :blank{}', (None, 'blank'), (0, 1, 0), []),
('@page :last {}', None, None, ['invalid @page selector']),
('@page : first {}', None, None, ['invalid @page selector']),
('@page foo:first {}', ('foo', 'first'), (1, 1, 0), []),
('@page bar :left {}', ('bar', 'left'), (1, 0, 1), []),
(r'@page \26:right {}', ('&', 'right'), (1, 0, 1), []),
('@page foo {}', ('foo', None), (1, 0, 0), []),
(r'@page \26 {}', ('&', None), (1, 0, 0), []),
('@page foo fist {}', None, None, ['invalid @page selector']),
('@page foo, bar {}', None, None, ['invalid @page selector']),
('@page foo&first {}', None, None, ['invalid @page selector']),
])
def test_selectors(css, expected_selector, expected_specificity,
expected_errors):
stylesheet = CSSPage3Parser().parse_stylesheet(css)
assert_errors(stylesheet.errors, expected_errors)
if stylesheet.rules:
assert len(stylesheet.rules) == 1
rule = stylesheet.rules[0]
assert rule.at_keyword == '@page'
selector = rule.selector
assert rule.specificity == expected_specificity
else:
selector = None
assert selector == expected_selector
@pytest.mark.parametrize(('css', 'expected_declarations',
'expected_rules', 'expected_errors'), [
('@page {}', [], [], []),
('@page { foo: 4; bar: z }',
[('foo', [('INTEGER', 4)]), ('bar', [('IDENT', 'z')])], [], []),
('''@page { foo: 4;
@top-center { content: "Awesome Title" }
@bottom-left { content: counter(page) }
bar: z
}''',
[('foo', [('INTEGER', 4)]), ('bar', [('IDENT', 'z')])],
[('@top-center', [('content', [('STRING', 'Awesome Title')])]),
('@bottom-left', [('content', [
('FUNCTION', 'counter', [('IDENT', 'page')])])])],
[]),
('''@page { foo: 4;
@bottom-top { content: counter(page) }
bar: z
}''',
[('foo', [('INTEGER', 4)]), ('bar', [('IDENT', 'z')])],
[],
['unknown at-rule in @page context: @bottom-top']),
('@page{} @top-right{}', [], [], [
'@top-right rule not allowed in stylesheet']),
('@page{ @top-right 4 {} }', [], [], [
'unexpected INTEGER token in @top-right rule header']),
# Not much error recovery tests here. This should be covered in test_css21
])
def test_content(css, expected_declarations, expected_rules, expected_errors):
stylesheet = CSSPage3Parser().parse_stylesheet(css)
assert_errors(stylesheet.errors, expected_errors)
def declarations(rule):
return [(decl.name, list(jsonify(decl.value)))
for decl in rule.declarations]
assert len(stylesheet.rules) == 1
rule = stylesheet.rules[0]
assert rule.at_keyword == '@page'
assert declarations(rule) == expected_declarations
rules = [(margin_rule.at_keyword, declarations(margin_rule))
for margin_rule in rule.at_rules]
assert rules == expected_rules

View File

@ -0,0 +1,452 @@
# coding: utf-8
"""
tinycss.token_data
------------------
Shared data for both implementations (Cython and Python) of the tokenizer.
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
import functools
import operator
import re
import string
import sys
# * Raw strings with the r'' notation are used so that \ do not need
# to be escaped.
# * Names and regexps are separated by a tabulation.
# * Macros are re-ordered so that only previous definitions are needed.
# * {} are used for macro substitution with ``string.Formatter``,
# so other uses of { or } have been doubled.
# * The syntax is otherwise compatible with re.compile.
# * Some parentheses were added to add capturing groups.
# (in unicode, DIMENSION and URI)
# *** Willful violation: ***
# Numbers can take a + or - sign, but the sign is a separate DELIM token.
# Since comments are allowed anywhere between tokens, this makes
# the following this is valid. It means 10 negative pixels:
# margin-top: -/**/10px
# This makes parsing numbers a pain, so instead well do the same is Firefox
# and make the sign part as of the 'num' macro. The above CSS will be invalid.
# See discussion:
# http://lists.w3.org/Archives/Public/www-style/2011Oct/0028.html
MACROS = r'''
nl \n|\r\n|\r|\f
w [ \t\r\n\f]*
nonascii [^\0-\237]
unicode \\([0-9a-f]{{1,6}})(\r\n|[ \n\r\t\f])?
simple_escape [^\n\r\f0-9a-f]
escape {unicode}|\\{simple_escape}
nmstart [_a-z]|{nonascii}|{escape}
nmchar [_a-z0-9-]|{nonascii}|{escape}
name {nmchar}+
ident [-]?{nmstart}{nmchar}*
num [-+]?(?:[0-9]*\.[0-9]+|[0-9]+)
string1 \"([^\n\r\f\\"]|\\{nl}|{escape})*\"
string2 \'([^\n\r\f\\']|\\{nl}|{escape})*\'
string {string1}|{string2}
badstring1 \"([^\n\r\f\\"]|\\{nl}|{escape})*\\?
badstring2 \'([^\n\r\f\\']|\\{nl}|{escape})*\\?
badstring {badstring1}|{badstring2}
badcomment1 \/\*[^*]*\*+([^/*][^*]*\*+)*
badcomment2 \/\*[^*]*(\*+[^/*][^*]*)*
badcomment {badcomment1}|{badcomment2}
baduri1 url\({w}([!#$%&*-~]|{nonascii}|{escape})*{w}
baduri2 url\({w}{string}{w}
baduri3 url\({w}{badstring}
baduri {baduri1}|{baduri2}|{baduri3}
'''.replace(r'\0', '\0').replace(r'\237', '\237')
# Removed these tokens. Instead, theyre tokenized as two DELIM each.
# INCLUDES ~=
# DASHMATCH |=
# They are only used in selectors but selectors3 also have ^=, *= and $=.
# We dont actually parse selectors anyway
# Re-ordered so that the longest match is always the first.
# For example, "url('foo')" matches URI, BAD_URI, FUNCTION and IDENT,
# but URI would always be a longer match than the others.
TOKENS = r'''
S [ \t\r\n\f]+
URI url\({w}({string}|([!#$%&*-\[\]-~]|{nonascii}|{escape})*){w}\)
BAD_URI {baduri}
FUNCTION {ident}\(
UNICODE-RANGE u\+[0-9a-f?]{{1,6}}(-[0-9a-f]{{1,6}})?
IDENT {ident}
ATKEYWORD @{ident}
HASH #{name}
DIMENSION ({num})({ident})
PERCENTAGE {num}%
NUMBER {num}
STRING {string}
BAD_STRING {badstring}
COMMENT \/\*[^*]*\*+([^/*][^*]*\*+)*\/
BAD_COMMENT {badcomment}
: :
; ;
{ \{{
} \}}
( \(
) \)
[ \[
] \]
CDO <!--
CDC -->
'''
# Strings with {macro} expanded
COMPILED_MACROS = {}
COMPILED_TOKEN_REGEXPS = [] # [(name, regexp.match)] ordered
COMPILED_TOKEN_INDEXES = {} # {name: i} helper for the C speedups
# Indexed by codepoint value of the first character of a token.
# Codepoints >= 160 (aka nonascii) all use the index 160.
# values are (i, name, regexp.match)
TOKEN_DISPATCH = []
try:
unichr
except NameError:
# Python 3
unichr = chr
unicode = str
def _init():
"""Import-time initialization."""
COMPILED_MACROS.clear()
for line in MACROS.splitlines():
if line.strip():
name, value = line.split('\t')
COMPILED_MACROS[name.strip()] = '(?:%s)' \
% value.format(**COMPILED_MACROS)
COMPILED_TOKEN_REGEXPS[:] = (
(
name.strip(),
re.compile(
value.format(**COMPILED_MACROS),
# Case-insensitive when matching eg. uRL(foo)
# but preserve the case in extracted groups
re.I
).match
)
for line in TOKENS.splitlines()
if line.strip()
for name, value in [line.split('\t')]
)
COMPILED_TOKEN_INDEXES.clear()
for i, (name, regexp) in enumerate(COMPILED_TOKEN_REGEXPS):
COMPILED_TOKEN_INDEXES[name] = i
dispatch = [[] for i in range(161)]
for chars, names in [
(' \t\r\n\f', ['S']),
('uU', ['URI', 'BAD_URI', 'UNICODE-RANGE']),
# \ is an escape outside of another token
(string.ascii_letters + '\\_-' + unichr(160), ['FUNCTION', 'IDENT']),
(string.digits + '.+-', ['DIMENSION', 'PERCENTAGE', 'NUMBER']),
('@', ['ATKEYWORD']),
('#', ['HASH']),
('\'"', ['STRING', 'BAD_STRING']),
('/', ['COMMENT', 'BAD_COMMENT']),
('<', ['CDO']),
('-', ['CDC']),
]:
for char in chars:
dispatch[ord(char)].extend(names)
for char in ':;{}()[]':
dispatch[ord(char)] = [char]
TOKEN_DISPATCH[:] = (
[
(index,) + COMPILED_TOKEN_REGEXPS[index]
for name in names
for index in [COMPILED_TOKEN_INDEXES[name]]
]
for names in dispatch
)
_init()
def _unicode_replace(match, int=int, unichr=unichr, maxunicode=sys.maxunicode):
codepoint = int(match.group(1), 16)
if codepoint <= maxunicode:
return unichr(codepoint)
else:
return '\N{REPLACEMENT CHARACTER}' # U+FFFD
UNICODE_UNESCAPE = functools.partial(
re.compile(COMPILED_MACROS['unicode'], re.I).sub,
_unicode_replace)
NEWLINE_UNESCAPE = functools.partial(
re.compile(r'()\\' + COMPILED_MACROS['nl']).sub,
'')
SIMPLE_UNESCAPE = functools.partial(
re.compile(r'\\(%s)' % COMPILED_MACROS['simple_escape'], re.I).sub,
# Same as r'\1', but faster on CPython
operator.methodcaller('group', 1))
FIND_NEWLINES = re.compile(COMPILED_MACROS['nl']).finditer
class Token(object):
"""A single atomic token.
.. attribute:: is_container
Always ``False``.
Helps to tell :class:`Token` apart from :class:`ContainerToken`.
.. attribute:: type
The type of token as a string:
``S``
A sequence of white space
``IDENT``
An identifier: a name that does not start with a digit.
A name is a sequence of letters, digits, ``_``, ``-``, escaped
characters and non-ASCII characters. Eg: ``margin-left``
``HASH``
``#`` followed immediately by a name. Eg: ``#ff8800``
``ATKEYWORD``
``@`` followed immediately by an identifier. Eg: ``@page``
``URI``
Eg: ``url(foo)`` The content may or may not be quoted.
``UNICODE-RANGE``
``U+`` followed by one or two hexadecimal
Unicode codepoints. Eg: ``U+20-00FF``
``INTEGER``
An integer with an optional ``+`` or ``-`` sign
``NUMBER``
A non-integer number with an optional ``+`` or ``-`` sign
``DIMENSION``
An integer or number followed immediately by an
identifier (the unit). Eg: ``12px``
``PERCENTAGE``
An integer or number followed immediately by ``%``
``STRING``
A string, quoted with ``"`` or ``'``
``:`` or ``;``
That character.
``DELIM``
A single character not matched in another token. Eg: ``,``
See the source of the :mod:`.token_data` module for the precise
regular expressions that match various tokens.
Note that other token types exist in the early tokenization steps,
but these are ignored, are syntax errors, or are later transformed
into :class:`ContainerToken` or :class:`FunctionToken`.
.. attribute:: value
The parsed value:
* INTEGER, NUMBER, PERCENTAGE or DIMENSION tokens: the numeric value
as an int or float.
* STRING tokens: the unescaped string without quotes
* URI tokens: the unescaped URI without quotes or
``url(`` and ``)`` markers.
* IDENT, ATKEYWORD or HASH tokens: the unescaped token,
with ``@`` or ``#`` markers left as-is
* Other tokens: same as :attr:`as_css`
*Unescaped* refers to the various escaping methods based on the
backslash ``\`` character in CSS syntax.
.. attribute:: unit
* DIMENSION tokens: the normalized (unescaped, lower-case)
unit name as a string. eg. ``'px'``
* PERCENTAGE tokens: the string ``'%'``
* Other tokens: ``None``
.. attribute:: line
The line number in the CSS source of the start of this token.
.. attribute:: column
The column number (inside a source line) of the start of this token.
"""
is_container = False
__slots__ = 'type', '_as_css', 'value', 'unit', 'line', 'column'
def __init__(self, type_, css_value, value, unit, line, column):
self.type = type_
self._as_css = css_value
self.value = value
self.unit = unit
self.line = line
self.column = column
def as_css(self):
"""
Return as an Unicode string the CSS representation of the token,
as parsed in the source.
"""
return self._as_css
def __repr__(self):
return ('<Token {0.type} at {0.line}:{0.column} {0.value!r}{1}>'
.format(self, self.unit or ''))
def __eq__(self, other):
if type(self) != type(other):
raise TypeError(
'Cannot compare {0} and {1}'.format(type(self), type(other)))
else:
return all(
self.type_ == other.type_,
self._as_css == other._as_css,
self.value == other.value,
self.unit == other.unit,
)
class ContainerToken(object):
"""A token that contains other (nested) tokens.
.. attribute:: is_container
Always ``True``.
Helps to tell :class:`ContainerToken` apart from :class:`Token`.
.. attribute:: type
The type of token as a string. One of ``{``, ``(``, ``[`` or
``FUNCTION``. For ``FUNCTION``, the object is actually a
:class:`FunctionToken`.
.. attribute:: unit
Always ``None``. Included to make :class:`ContainerToken` behave
more like :class:`Token`.
.. attribute:: content
A list of :class:`Token` or nested :class:`ContainerToken`,
not including the opening or closing token.
.. attribute:: line
The line number in the CSS source of the start of this token.
.. attribute:: column
The column number (inside a source line) of the start of this token.
"""
is_container = True
unit = None
__slots__ = 'type', '_css_start', '_css_end', 'content', 'line', 'column'
def __init__(self, type_, css_start, css_end, content, line, column):
self.type = type_
self._css_start = css_start
self._css_end = css_end
self.content = content
self.line = line
self.column = column
def as_css(self):
"""
Return as an Unicode string the CSS representation of the token,
as parsed in the source.
"""
parts = [self._css_start]
parts.extend(token.as_css() for token in self.content)
parts.append(self._css_end)
return ''.join(parts)
format_string = '<ContainerToken {0.type} at {0.line}:{0.column}>'
def __repr__(self):
return (self.format_string + ' {0.content}').format(self)
class FunctionToken(ContainerToken):
"""A specialized :class:`ContainerToken` for a ``FUNCTION`` group.
Has an additional attribute:
.. attribute:: function_name
The unescaped name of the function, with the ``(`` marker removed.
"""
__slots__ = 'function_name',
def __init__(self, type_, css_start, css_end, function_name, content,
line, column):
super(FunctionToken, self).__init__(
type_, css_start, css_end, content, line, column)
# Remove the ( marker:
self.function_name = function_name[:-1]
format_string = ('<FunctionToken {0.function_name}() at '
'{0.line}:{0.column}>')
class TokenList(list):
"""
A mixed list of :class:`~.token_data.Token` and
:class:`~.token_data.ContainerToken` objects.
This is a subclass of the builtin :class:`~builtins.list` type.
It can be iterated, indexed and sliced as usual, but also has some
additional API:
"""
@property
def line(self):
"""The line number in the CSS source of the first token."""
return self[0].line
@property
def column(self):
"""The column number (inside a source line) of the first token."""
return self[0].column
def as_css(self):
"""
Return as an Unicode string the CSS representation of the tokens,
as parsed in the source.
"""
return ''.join(token.as_css() for token in self)

View File

@ -0,0 +1,215 @@
# coding: utf-8
"""
tinycss.tokenizer
-----------------
Tokenizer for the CSS core syntax:
http://www.w3.org/TR/CSS21/syndata.html#tokenization
This is the pure-python implementation. See also speedups.pyx
:copyright: (c) 2012 by Simon Sapin.
:license: BSD, see LICENSE for more details.
"""
from __future__ import unicode_literals
from . import token_data
def tokenize_flat(
css_source, ignore_comments=True,
# Make these local variable to avoid global lookups in the loop
tokens_dispatch=token_data.TOKEN_DISPATCH,
unicode_unescape=token_data.UNICODE_UNESCAPE,
newline_unescape=token_data.NEWLINE_UNESCAPE,
simple_unescape=token_data.SIMPLE_UNESCAPE,
find_newlines=token_data.FIND_NEWLINES,
Token=token_data.Token,
len=len,
int=int,
float=float,
list=list,
_None=None):
"""
:param css_source:
CSS as an unicode string
:param ignore_comments:
if true (the default) comments will not be included in the
return value
:return:
An iterator of :class:`Token`
"""
pos = 0
line = 1
column = 1
source_len = len(css_source)
tokens = []
while pos < source_len:
char = css_source[pos]
if char in ':;{}()[]':
type_ = char
css_value = char
else:
codepoint = min(ord(char), 160)
for _index, type_, regexp in tokens_dispatch[codepoint]:
match = regexp(css_source, pos)
if match:
# First match is the longest. See comments on TOKENS above.
css_value = match.group()
break
else:
# No match.
# "Any other character not matched by the above rules,
# and neither a single nor a double quote."
# ... but quotes at the start of a token are always matched
# by STRING or BAD_STRING. So DELIM is any single character.
type_ = 'DELIM'
css_value = char
length = len(css_value)
next_pos = pos + length
# A BAD_COMMENT is a comment at EOF. Ignore it too.
if not (ignore_comments and type_ in ('COMMENT', 'BAD_COMMENT')):
# Parse numbers, extract strings and URIs, unescape
unit = _None
if type_ == 'DIMENSION':
value = match.group(1)
value = float(value) if '.' in value else int(value)
unit = match.group(2)
unit = simple_unescape(unit)
unit = unicode_unescape(unit)
unit = unit.lower() # normalize
elif type_ == 'PERCENTAGE':
value = css_value[:-1]
value = float(value) if '.' in value else int(value)
unit = '%'
elif type_ == 'NUMBER':
value = css_value
if '.' in value:
value = float(value)
else:
value = int(value)
type_ = 'INTEGER'
elif type_ in ('IDENT', 'ATKEYWORD', 'HASH', 'FUNCTION'):
value = simple_unescape(css_value)
value = unicode_unescape(value)
elif type_ == 'URI':
value = match.group(1)
if value and value[0] in '"\'':
value = value[1:-1] # Remove quotes
value = newline_unescape(value)
value = simple_unescape(value)
value = unicode_unescape(value)
elif type_ == 'STRING':
value = css_value[1:-1] # Remove quotes
value = newline_unescape(value)
value = simple_unescape(value)
value = unicode_unescape(value)
# BAD_STRING can only be one of:
# * Unclosed string at the end of the stylesheet:
# Close the string, but this is not an error.
# Make it a "good" STRING token.
# * Unclosed string at the (unescaped) end of the line:
# Close the string, but this is an error.
# Leave it as a BAD_STRING, dont bother parsing it.
# See http://www.w3.org/TR/CSS21/syndata.html#parsing-errors
elif type_ == 'BAD_STRING' and next_pos == source_len:
type_ = 'STRING'
value = css_value[1:] # Remove quote
value = newline_unescape(value)
value = simple_unescape(value)
value = unicode_unescape(value)
else:
value = css_value
tokens.append(Token(type_, css_value, value, unit, line, column))
pos = next_pos
newlines = list(find_newlines(css_value))
if newlines:
line += len(newlines)
# Add 1 to have lines start at column 1, not 0
column = length - newlines[-1].end() + 1
else:
column += length
return tokens
def regroup(tokens):
"""
Match pairs of tokens: () [] {} function()
(Strings in "" or '' are taken care of by the tokenizer.)
Opening tokens are replaced by a :class:`ContainerToken`.
Closing tokens are removed. Unmatched closing tokens are invalid
but left as-is. All nested structures that are still open at
the end of the stylesheet are implicitly closed.
:param tokens:
a *flat* iterable of tokens, as returned by :func:`tokenize_flat`.
:return:
A tree of tokens.
"""
# "global" objects for the inner recursion
pairs = {'FUNCTION': ')', '(': ')', '[': ']', '{': '}'}
tokens = iter(tokens)
eof = [False]
def _regroup_inner(stop_at=None, tokens=tokens, pairs=pairs, eof=eof,
ContainerToken=token_data.ContainerToken,
FunctionToken=token_data.FunctionToken):
for token in tokens:
type_ = token.type
if type_ == stop_at:
return
end = pairs.get(type_)
if end is None:
yield token # Not a grouping token
else:
assert not isinstance(token, ContainerToken), (
'Token looks already grouped: {0}'.format(token))
content = list(_regroup_inner(end))
if eof[0]:
end = '' # Implicit end of structure at EOF.
if type_ == 'FUNCTION':
yield FunctionToken(token.type, token.as_css(), end,
token.value, content,
token.line, token.column)
else:
yield ContainerToken(token.type, token.as_css(), end,
content,
token.line, token.column)
else:
eof[0] = True # end of file/stylesheet
return _regroup_inner()
def tokenize_grouped(css_source, ignore_comments=True):
"""
:param css_source:
CSS as an unicode string
:param ignore_comments:
if true (the default) comments will not be included in the
return value
:return:
An iterator of :class:`Token`
"""
return regroup(tokenize_flat(css_source, ignore_comments))
# Optional Cython version of tokenize_flat
# Make both versions available with explicit names for tests.
python_tokenize_flat = tokenize_flat
try:
from . import speedups
except ImportError:
cython_tokenize_flat = None
else:
cython_tokenize_flat = speedups.tokenize_flat
# Default to the Cython version if available
tokenize_flat = cython_tokenize_flat

View File

@ -6,5 +6,5 @@
"tetra"
],
"title": "Portal Auth",
"version": "1.9"
"version": "2.0"
}