diff --git a/.circleci/config.yml b/.circleci/config.yml index d57938e67..1c3964763 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -123,11 +123,53 @@ jobs: target: "aarch64-linux-android" + build_test_docs_wheel: + machine: True + steps: + - checkout + # - run: docker pull deltachat/doxygen + - run: docker pull deltachat/coredeps + - run: + name: build docs, run tests and build wheels + command: ci_scripts/ci_run.sh + environment: + TESTS: 1 + DOCS: 1 + + - run: + name: copying docs and wheels to workspace + command: | + mkdir -p workspace/python + # cp -av docs workspace/c-docs + cp -av python/.docker-tox/wheelhouse workspace/ + cp -av python/doc/_build/ workspace/py-docs + + - persist_to_workspace: + root: workspace + paths: + # - c-docs + - py-docs + - wheelhouse + + upload_docs_wheels: + machine: True + steps: + - checkout + - attach_workspace: + at: workspace + - run: ls -laR workspace + - run: ci_scripts/ci_upload.sh workspace/py-docs workspace/wheelhouse + + workflows: version: 2.1 test: jobs: + - build_test_docs_wheel + - upload_docs_wheels: + requires: + - build_test_docs_wheel - cargo_fetch - rustfmt: requires: diff --git a/ci_scripts/README.md b/ci_scripts/README.md new file mode 100644 index 000000000..6a2397542 --- /dev/null +++ b/ci_scripts/README.md @@ -0,0 +1,52 @@ + +# Continuous Integration Scripts for Delta Chat + +Continuous Integration is run through CircleCI +but is largely independent of it. + + +## Generating docker containers for performing build step work + +All tests, docs and wheel building is run in docker containers: + +- **coredeps/Dockerfile** specifies an image that contains all + of Delta Chat's core dependencies as linkable libraries. + It also serves to run python tests and build wheels + (binary packages for Python). + +- **doxygen/Dockerfile** specifies an image that contains + the doxygen tool which is used to generate C-docs. + +To run tests locally you can pull existing images from "docker.io", +the hub for sharing Docker images:: + + docker pull deltachat/coredeps + docker pull deltachat/doxygen + +or you can build the docker images yourself locally +to avoid the relatively large download:: + + cd ci_scripts # where all CI things are + docker build -t deltachat/coredeps docker-coredeps + docker build -t deltachat/doxygen docker-doxygen + +## ci_run.sh (main entrypoint called by circle-ci) + +Once you have the docker images available +you can run python testing, documentation generation +and building binary wheels:: + + sh DOCS=1 TESTS=1 ci_scripts/ci_run.sh + +## ci_upload.sh (uploading artifacts on success) + +- python docs to `https://py.delta.chat/_unofficial_unreleased_docs/` + +- doxygen docs to `https://c.delta.chat/_unofficial_unreleased_docs/` + +- python wheels to `https://m.devpi.net/dc/` + so that you install fully self-contained wheels like this: + `pip install -U -i https://m.devpi.net/dc/ deltachat` + + + diff --git a/ci_scripts/ci_run.sh b/ci_scripts/ci_run.sh new file mode 100755 index 000000000..3291bd10f --- /dev/null +++ b/ci_scripts/ci_run.sh @@ -0,0 +1,22 @@ +# perform CI jobs on PRs and after merges to master. +# triggered from .circleci/config.yml + +set -e -x + +export BRANCH=${CIRCLE_BRANCH:-test7} + +# run doxygen on c-source (needed by later doc-generation steps). +# XXX modifies the host filesystem docs/xml and docs/html directories +# XXX which you can then only remove with sudo as they belong to root + +# XXX we don't do doxygen doc generation with Rust anymore, needs to be +# substituted with rust-docs +#if [ -n "$DOCS" ] ; then +# docker run --rm -it -v $PWD:/mnt -w /mnt/docs deltachat/doxygen doxygen +#fi + +# run everything else inside docker (TESTS, DOCS, WHEELS) +docker run -e BRANCH -e TESTS -e DOCS \ + --rm -it -v $(pwd):/mnt -w /mnt \ + deltachat/coredeps ci_scripts/run_all.sh + diff --git a/ci_scripts/ci_upload.sh b/ci_scripts/ci_upload.sh new file mode 100755 index 000000000..3cd457a92 --- /dev/null +++ b/ci_scripts/ci_upload.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +if [ -z "$DEVPI_LOGIN" ] ; then + echo "required: password for 'dc' user on https://m.devpi/net/dc index" + exit 1 +fi + +set -xe + +#DOXYDOCDIR=${1:?directory where doxygen docs to be found} +PYDOCDIR=${1:?directory with python docs} +WHEELHOUSEDIR=${2:?directory with pre-built wheels} + +export BRANCH=${CIRCLE_BRANCH:?specify branch for uploading purposes} + + +# python docs to py.delta.chat +rsync -avz \ + -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \ + "$PYDOCDIR/html/" \ + delta@py.delta.chat:build/${BRANCH} + +# C docs to c.delta.chat +#rsync -avz \ +# -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \ +# "$DOXYDOCDIR/html/" \ +# delta@py.delta.chat:build-c/${BRANCH} + +echo ----------------------- +echo upload wheels +echo ----------------------- + +# Bundle external shared libraries into the wheels +pushd $WHEELHOUSEDIR + +pip install devpi-client +devpi use https://m.devpi.net +devpi login dc --password $DEVPI_LOGIN + +devpi use dc/$BRANCH || { + devpi index -c $BRANCH + devpi use dc/$BRANCH +} +devpi index $BRANCH bases=/root/pypi +devpi upload deltachat*.whl + +popd diff --git a/ci_scripts/docker-coredeps/Dockerfile b/ci_scripts/docker-coredeps/Dockerfile new file mode 100644 index 000000000..b3aa718ab --- /dev/null +++ b/ci_scripts/docker-coredeps/Dockerfile @@ -0,0 +1,25 @@ +FROM quay.io/pypa/manylinux1_x86_64 + +# Configure ld.so/ldconfig and pkg-config +RUN echo /usr/local/lib64 > /etc/ld.so.conf.d/local.conf && \ + echo /usr/local/lib >> /etc/ld.so.conf.d/local.conf +ENV PKG_CONFIG_PATH /usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig + +ENV PIP_DISABLE_PIP_VERSION_CHECK 1 + +# Install python tools (auditwheels,tox, ...) +ADD deps/build_python.sh /builder/build_python.sh +RUN mkdir tmp1 && cd tmp1 && bash /builder/build_python.sh && cd .. && rm -r tmp1 + +# Install Rust nightly +ADD deps/build_rust.sh /builder/build_rust.sh +RUN mkdir tmp1 && cd tmp1 && bash /builder/build_rust.sh && cd .. && rm -r tmp1 + +# Install a recent Perl, needed to install OpenSSL +ADD deps/build_perl.sh /builder/build_perl.sh +RUN mkdir tmp1 && cd tmp1 && bash /builder/build_perl.sh && cd .. && rm -r tmp1 + +# Install OpenSSL +ADD deps/build_openssl.sh /builder/build_openssl.sh +RUN mkdir tmp1 && cd tmp1 && bash /builder/build_openssl.sh && cd .. && rm -r tmp1 + diff --git a/ci_scripts/docker-coredeps/deps/build_openssl.sh b/ci_scripts/docker-coredeps/deps/build_openssl.sh new file mode 100755 index 000000000..6f609186f --- /dev/null +++ b/ci_scripts/docker-coredeps/deps/build_openssl.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e -x + +OPENSSL_VERSION=1.1.1a +OPENSSL_SHA256=fc20130f8b7cbd2fb918b2f14e2f429e109c31ddd0fb38fc5d71d9ffed3f9f41 + +curl -O https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz +echo "${OPENSSL_SHA256} openssl-${OPENSSL_VERSION}.tar.gz" | sha256sum -c - +tar xzf openssl-${OPENSSL_VERSION}.tar.gz +cd openssl-${OPENSSL_VERSION} +./config shared no-ssl2 no-ssl3 -fPIC --prefix=/usr/local + +sed -i "s/^SHLIB_MAJOR=.*/SHLIB_MAJOR=200/" Makefile && \ +sed -i "s/^SHLIB_MINOR=.*/SHLIB_MINOR=0.0/" Makefile && \ +sed -i "s/^SHLIB_VERSION_NUMBER=.*/SHLIB_VERSION_NUMBER=200.0.0/" Makefile + +make depend +make +make install_sw install_ssldirs +ldconfig -v | grep ssl diff --git a/ci_scripts/docker-coredeps/deps/build_perl.sh b/ci_scripts/docker-coredeps/deps/build_perl.sh new file mode 100755 index 000000000..4acb51c3d --- /dev/null +++ b/ci_scripts/docker-coredeps/deps/build_perl.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +PERL_VERSION=5.28.0 +PERL_SHA256=7e929f64d4cb0e9d1159d4a59fc89394e27fa1f7004d0836ca0d514685406ea8 +curl -O https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.gz +echo "${PERL_SHA256} perl-${PERL_VERSION}.tar.gz" | sha256sum -c - +tar xzf perl-${PERL_VERSION}.tar.gz +cd perl-${PERL_VERSION} + +./Configure -de +make +make install diff --git a/ci_scripts/docker-coredeps/deps/build_python.sh b/ci_scripts/docker-coredeps/deps/build_python.sh new file mode 100755 index 000000000..b67240651 --- /dev/null +++ b/ci_scripts/docker-coredeps/deps/build_python.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -x -e + +# we use the python3.5 environment as the base environment +/opt/python/cp35-cp35m/bin/pip install tox devpi-client auditwheel + +pushd /usr/bin + +ln -s /opt/_internal/cpython-3.5.*/bin/tox +ln -s /opt/_internal/cpython-3.5.*/bin/devpi +ln -s /opt/_internal/cpython-3.5.*/bin/auditwheel + +popd diff --git a/ci_scripts/docker-coredeps/deps/build_rust.sh b/ci_scripts/docker-coredeps/deps/build_rust.sh new file mode 100755 index 000000000..800ea81f2 --- /dev/null +++ b/ci_scripts/docker-coredeps/deps/build_rust.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e -x + +# Install Rust +curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain nightly-2019-04-19 -y +export PATH=/root/.cargo/bin:$PATH +rustc --version + +# remove some 300-400 MB that we don't need for automated builds +rm -rf /root/.rustup/toolchains/nightly-2019-04-19-x86_64-unknown-linux-gnu/share/ diff --git a/ci_scripts/docker-coredeps/deps/run_all.sh b/ci_scripts/docker-coredeps/deps/run_all.sh new file mode 100755 index 000000000..93fca67b5 --- /dev/null +++ b/ci_scripts/docker-coredeps/deps/run_all.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# +# Build the Delta Chat C/Rust library +# +set -e -x + +# perform clean build of core and install +export TOXWORKDIR=.docker-tox + +# build core library + +cargo build --release -p deltachat_ffi + +# configure access to a base python and +# to several python interpreters needed by tox below +export PATH=$PATH:/opt/python/cp35-cp35m/bin +export PYTHONDONTWRITEBYTECODE=1 +pushd /bin +ln -s /opt/python/cp27-cp27m/bin/python2.7 +ln -s /opt/python/cp36-cp36m/bin/python3.6 +ln -s /opt/python/cp37-cp37m/bin/python3.7 +popd + +# +# run python tests +# + +if [ -n "$TESTS" ]; then + + echo ---------------- + echo run python tests + echo ---------------- + + pushd python + # first run all tests ... + rm -rf tests/__pycache__ + rm -rf src/deltachat/__pycache__ + export PYTHONDONTWRITEBYTECODE=1 + tox --workdir "$TOXWORKDIR" -e py27,py35,py36,py37 + popd +fi + + +if [ -n "$DOCS" ]; then + echo ----------------------- + echo generating python docs + echo ----------------------- + (cd python && tox --workdir "$TOXWORKDIR" -e doc) +fi diff --git a/ci_scripts/docker-doxygen/Dockerfile b/ci_scripts/docker-doxygen/Dockerfile new file mode 100644 index 000000000..c931294fe --- /dev/null +++ b/ci_scripts/docker-doxygen/Dockerfile @@ -0,0 +1,5 @@ +FROM debian:stable + +# this is tagged as deltachat/doxygen + +RUN apt-get update && apt-get install -y doxygen diff --git a/ci_scripts/run_all.sh b/ci_scripts/run_all.sh new file mode 100755 index 000000000..2edf71f5a --- /dev/null +++ b/ci_scripts/run_all.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# +# Build the Delta Chat C/Rust library +# typically run in a docker container that contains all library deps +# but should also work outside if you have the dependencies installed +# on your system. + +set -e -x + +# perform clean build of core and install +export TOXWORKDIR=.docker-tox + +# install core lib + +export PATH=/root/.cargo/bin:$PATH +cargo build --release -p deltachat_ffi +# cargo test --all --all-features + +# make sure subsequent compiler invocations find header and libraries +export CFLAGS=-I`pwd`/deltachat-ffi +export LD_LIBRARY_PATH=`pwd`/target/release + +# configure access to a base python and +# to several python interpreters needed by tox below +export PATH=$PATH:/opt/python/cp35-cp35m/bin +export PYTHONDONTWRITEBYTECODE=1 +pushd /bin +ln -s /opt/python/cp27-cp27m/bin/python2.7 +ln -s /opt/python/cp36-cp36m/bin/python3.6 +ln -s /opt/python/cp37-cp37m/bin/python3.7 +popd + +if [ -n "$TESTS" ]; then + + pushd python + # prepare a clean tox run + rm -rf tests/__pycache__ + rm -rf src/deltachat/__pycache__ + export PYTHONDONTWRITEBYTECODE=1 + + # run tox + tox --workdir "$TOXWORKDIR" -e py27,py35,py36,py37,auditwheels + popd +fi + + +if [ -n "$DOCS" ]; then + echo ----------------------- + echo generating python docs + echo ----------------------- + (cd python && tox --workdir "$TOXWORKDIR" -e doc) +fi diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index e7787b937..a280132a7 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -229,8 +229,6 @@ char* dc_get_config (dc_context_t*, const char* key); char* dc_get_info (dc_context_t*); char* dc_get_oauth2_url (dc_context_t*, const char* addr, const char* redirect); char* dc_get_version_str (void); -void dc_openssl_init_not_required (void); -void dc_no_compound_msgs (void); // deprecated // connect @@ -437,8 +435,7 @@ const uintptr_t* dc_array_get_raw (const dc_array_t*); * Rendering the deaddrop in the described way * would not add extra work in the UI then. */ -dc_chatlist_t* dc_chatlist_new (dc_context_t*); -void dc_chatlist_empty (dc_chatlist_t*); +// dc_chatlist_t* dc_chatlist_new (dc_context_t*); void dc_chatlist_unref (dc_chatlist_t*); size_t dc_chatlist_get_cnt (const dc_chatlist_t*); uint32_t dc_chatlist_get_chat_id (const dc_chatlist_t*, size_t index); @@ -470,8 +467,6 @@ dc_context_t* dc_chatlist_get_context (dc_chatlist_t*); #define DC_CHAT_TYPE_VERIFIED_GROUP 130 -dc_chat_t* dc_chat_new (dc_context_t*); -void dc_chat_empty (dc_chat_t*); void dc_chat_unref (dc_chat_t*); uint32_t dc_chat_get_id (const dc_chat_t*); @@ -578,8 +573,6 @@ void dc_msg_latefiling_mediasize (dc_msg_t*, int width, int height, #define DC_CONTACT_ID_LAST_SPECIAL 9 -dc_contact_t* dc_contact_new (dc_context_t*); -void dc_contact_empty (dc_contact_t*); void dc_contact_unref (dc_contact_t*); uint32_t dc_contact_get_id (const dc_contact_t*); char* dc_contact_get_addr (const dc_contact_t*); diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 000000000..5b13b9ef7 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,4 @@ +doc/_build/ +build/ +dist/ +src/deltachat/capi.abi3.so diff --git a/python/CHANGELOG b/python/CHANGELOG new file mode 100644 index 000000000..87f6449cd --- /dev/null +++ b/python/CHANGELOG @@ -0,0 +1,73 @@ +0.9.1-dev +--------- + +- use docker image for building wheels +- fix code documentation links + +0.9.0 +----- + +- build against latest deltachat core (0.41 ++) with RPGP compiled in +- streamline package building +- allow installing from devpi.net package index + + +0.8.2 +----- + +- adapt to deleted and new event names + +0.8.1 +----- + +- changed license to MPL 2.0, see https://github.com/deltachat/deltachat-core/issues/403 + for the confirmation from contributors. + + +0.8.0 +----- + +- now depends on core tagged as v0.23.0 + +- drop send_image and other helper methods in favor of + just a Chat.send_file() that takes all kinds of media types + +- introduce Message.get_mime_headers() which returns + the headers for an incoming message if the ``save_mime_headers`` + config option is set. + +- add Message.time_received attribute, now returning an unaware + datetime + +- set_config() and get_config() now throw an error if a config option + does not exist + +- some API renames + +0.7.1 +----- + +- add Account.get_infostring() to show low-level info about account state + + +0.7 +--- + +- add Chat.delete(), Chat.send_image() and Chat.send_file() + +- renamed Chat.send_text_message to Chat.send_text() + (all other send methods also have no "_msg" at the end + as it's pretty clear Chat.send_X is going to send a message + to the chat) + +- new Account.create_message() to create new messages + that are not in the database (yet) + +- refined logging of events which now shows relative timestamps + + +0.6 +--- + +- initial release with full low level C-API, and a first + high level API diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 000000000..14e2f777f --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/python/MANIFEST.in b/python/MANIFEST.in new file mode 100644 index 000000000..a5117f103 --- /dev/null +++ b/python/MANIFEST.in @@ -0,0 +1,3 @@ +include tox.ini +recursive-include tests *.py +recursive-include tests/data * diff --git a/python/README.rst b/python/README.rst new file mode 100644 index 000000000..6b4a05408 --- /dev/null +++ b/python/README.rst @@ -0,0 +1,161 @@ +========================= +deltachat python bindings +========================= + +This package provides bindings to the deltachat-core_ Rust -library +which provides imap/smtp/crypto handling as well as chat/group/messages +handling to Android, Desktop and IO user interfaces. + +Installing pre-built packages (linux-only) +========================================== + +If you have a linux system you may install the ``deltachat`` binary "wheel" package +without any "build-from-source" steps. + +1. `Install virtualenv `_, + then create a fresh python environment and activate it in your shell:: + + virtualenv -p python3 venv + source venv/bin/activate + + Afterwards, invoking ``python`` or ``pip install`` will only + modify files in your ``venv`` directory and leave your system installation + alone. + +2. Install the wheel for linux:: + + pip install deltachat + + Verify it worked by typing:: + + python -c "import deltachat" + + +Installing a wheel from a PR/branch +--------------------------------------- + +For Linux, we automatically build wheels for all github PR branches +and push them to a python package index. To install the latest github ``master`` branch:: + + pip install -i https://m.devpi.net/dc/master deltachat + + +Installing bindings from source +=============================== + +If you can't use "binary" method above then you need to compile +to core deltachat library:: + + git clone https://github.com/deltachat/deltachat-core-rust + cd deltachat-core-rust + cargo build -p deltachat_ffi --release + +This will result in a ``libdeltachat.so`` and ``deltachat.h`` file +in the ``deltachat_ffi`` directory. These files are needed for +creating the python bindings for deltachat:: + + cd python + CFLAGS=I`pwd`/../deltachat-ffi pip install -e . + +You then need to set the load-dynamic-library path in your shell:: + + export LD_LIBRARY_PATH=`pwd`/../target/release + +so that importing the bindings finds the correct library:: + + python -c 'import deltachat ; print(deltachat.__version__)' + +This should print your deltachat bindings version. + +.. note:: + + If you can help to automate the building of wheels for Mac or Windows, + that'd be much appreciated! please then get + `in contact with us `_. + + +Code examples +============= + +You may look at `examples `_. + + +Running tests +============= + +Get a checkout of the `deltachat-core github repository`_ and type:: + + cd python + export CFLAGS=I`pwd`/../deltachat-ffi + export LD_LIBRARY_PATH=`pwd`/../target/release + pip install tox + tox + +If you want to run functional tests with real +e-mail test accounts, generate a "liveconfig" file where each +lines contains test account settings, for example:: + + # 'liveconfig' file specifying imap/smtp accounts + addr=some-email@example.org mail_pw=password + addr=other-email@example.org mail_pw=otherpassword + +The "keyword=value" style allows to specify any +`deltachat account config setting `_ so you can also specify smtp or imap servers, ports, ssl modes etc. +Typically DC's automatic configuration allows to not specify these settings. + +You can now run tests with this ``liveconfig`` file:: + + tox -- --liveconfig liveconfig + + +.. _`deltachat-core github repository`: https://github.com/deltachat/deltachat-core-rust +.. _`deltachat-core`: https://github.com/deltachat/deltachat-core-rust + + +Building manylinux1 wheels +========================== + +Building portable manylinux1 wheels which come with libdeltachat.so +and all it's dependencies is easy using the provided docker tooling. + +using docker pull / premade images +------------------------------------ + +We publish a build environment under the ``deltachat/wheel`` tag so +that you can pull it from the ``hub.docker.com`` site's "deltachat" +organization:: + + $ docker pull deltachat/wheel + +The ``deltachat/wheel`` image can be used to build both libdeltachat.so +and the Python wheels:: + + $ docker run --rm -it -v $(pwd):/io/ deltachat/wheel /io/python/wheelbuilder/build-wheels.sh + +This command runs a script within the image, after mounting ``$(pwd)`` as ``/io`` within +the docker image. The script is specified as a path within the docker image's filesystem. +The resulting wheel files will be in ``python/wheelhouse``. + + +Optionally build your own docker image +-------------------------------------- + +If you want to build your own custom docker image you can do this:: + + $ cd deltachat-core # cd to deltachat-core checkout directory + $ docker build -t deltachat/wheel python/wheelbuilder/ + +This will use the ``python/wheelbuilder/Dockerfile`` to build +up docker image called ``deltachat/wheel``. You can afterwards +find it with:: + + $ docker images + + +Troubleshooting +--------------- + +On more recent systems running the docker image may crash. You can +fix this by adding ``vsyscall=emulate`` to the Linux kernel boot +arguments commandline. E.g. on Debian you'd add this to +``GRUB_CMDLINE_LINUX_DEFAULT`` in ``/etc/default/grub``. diff --git a/python/doc/Makefile b/python/doc/Makefile new file mode 100644 index 000000000..7f6b41af7 --- /dev/null +++ b/python/doc/Makefile @@ -0,0 +1,197 @@ +# Makefile for Sphinx documentation +# + +VERSION = $(shell python -c "import conf ; print(conf.version)") +DOCZIP = devpi-$(VERSION).doc.zip +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build +RSYNCOPTS = -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + +export HOME=/tmp/home +export TESTHOME=$(HOME) + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +# This variable is not auto generated as the order is important. +USER_MAN_CHAPTERS = commands\ + user\ + indices\ + packages\ +# userman/index.rst\ +# userman/devpi_misc.rst\ +# userman/devpi_concepts.rst\ + + +#export DEVPI_CLIENTDIR=$(CURDIR)/.tmp_devpi_user_man/client +#export DEVPI_SERVERDIR=$(CURDIR)/.tmp_devpi_user_man/server + +chapter = commands + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp \ + epub latex latexpdf text man changes linkcheck doctest gettext install \ + quickstart-releaseprocess quickstart-pypimirror quickstart-server regen \ + prepare-quickstart\ + regen.server-fresh regen.server-restart regen.server-clean\ + regen.uman-all regen.uman + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo + @echo "User Manual Regen Targets" + @echo " regen.uman regenerates page. of the user manual chapeter e.g. regen.uman chapter=..." + @echo " regen.uman-all regenerates the user manual" + @echo " regen.uman-clean stop temp server and clean up directory" + @echo " Chapter List: $(USER_MAN_CHAPTERS)" + +clean: + -rm -rf $(BUILDDIR)/* + +version: + @echo "version $(VERSION)" + +doczip: html + python doczip.py $(DOCZIP) _build/html + +install: html + rsync -avz $(RSYNCOPTS) _build/html/ delta@py.delta.chat:build/master + + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/devpi.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/devpi.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/devpi" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/devpi" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + + diff --git a/python/doc/_static/custom.css b/python/doc/_static/custom.css new file mode 100644 index 000000000..8949e0019 --- /dev/null +++ b/python/doc/_static/custom.css @@ -0,0 +1,17 @@ +/* customizations to Alabaster theme . */ + +div.document { + width: 1480px; +} + +div.body { + max-width: 1280px; +} + +div.globaltoc { + font-size: 1.4em; +} + +img.logo { + height: 120px; +} diff --git a/python/doc/_static/delta-chat.svg b/python/doc/_static/delta-chat.svg new file mode 100755 index 000000000..819f9034d --- /dev/null +++ b/python/doc/_static/delta-chat.svg @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/python/doc/_static/favicon.ico b/python/doc/_static/favicon.ico new file mode 100644 index 000000000..dffa2999f Binary files /dev/null and b/python/doc/_static/favicon.ico differ diff --git a/python/doc/_templates/globaltoc.html b/python/doc/_templates/globaltoc.html new file mode 100644 index 000000000..0901a0d90 --- /dev/null +++ b/python/doc/_templates/globaltoc.html @@ -0,0 +1,20 @@ + +
+ + +external links: + + +#deltachat [freenode] + +
diff --git a/python/doc/_templates/sidebarintro.html b/python/doc/_templates/sidebarintro.html new file mode 100644 index 000000000..dbb2f7044 --- /dev/null +++ b/python/doc/_templates/sidebarintro.html @@ -0,0 +1 @@ +

deltachat {{release}}

diff --git a/python/doc/api.rst b/python/doc/api.rst new file mode 100644 index 000000000..4e8e1e9a1 --- /dev/null +++ b/python/doc/api.rst @@ -0,0 +1,54 @@ + +high level API reference +======================== + +.. note:: + + This API is work in progress and may change in versions prior to 1.0. + +- :class:`deltachat.account.Account` (your main entry point, creates the + other classes) +- :class:`deltachat.chatting.Contact` +- :class:`deltachat.chatting.Chat` +- :class:`deltachat.message.Message` +- :class:`deltachat.message.MessageType` +- :class:`deltachat.message.MessageState` + +Account +------- + +.. autoclass:: deltachat.account.Account + :members: + + +Contact +------- + +.. autoclass:: deltachat.chatting.Contact + :members: + +Chat +---- + +.. autoclass:: deltachat.chatting.Chat + :members: + +Message +------- + +.. autoclass:: deltachat.message.Message + :members: + +MessageType +------------ + +.. autoclass:: deltachat.message.MessageType + :members: + +MessageState +------------ + +.. autoclass:: deltachat.message.MessageState + :members: + + diff --git a/python/doc/capi.rst b/python/doc/capi.rst new file mode 100644 index 000000000..67cbf0884 --- /dev/null +++ b/python/doc/capi.rst @@ -0,0 +1,7 @@ + +C deltachat interface +===================== + +See :doc:`lapi` for accessing many of the below functions +through the ``deltachat.capi.lib`` namespace. + diff --git a/python/doc/changelog.rst b/python/doc/changelog.rst new file mode 100644 index 000000000..db2b47a69 --- /dev/null +++ b/python/doc/changelog.rst @@ -0,0 +1,4 @@ +Changelog for deltachat-core's Python bindings +============================================== + +.. include:: ../CHANGELOG diff --git a/python/doc/conf.py b/python/doc/conf.py new file mode 100644 index 000000000..108a39bba --- /dev/null +++ b/python/doc/conf.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +# +# devpi documentation build configuration file, created by +# sphinx-quickstart on Mon Jun 3 16:11:22 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +from deltachat import __version__ as release +version = ".".join(release.split(".")[:2]) + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + #'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + 'breathe', + #'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'deltachat' +copyright = u'2018, holger krekel and contributors' + + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['sketch', '_build', "attic"] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- breathe options ------ + +breathe_projects = { + "deltachat": "../../docs/xml/" +} + +breathe_default_project = "deltachat" + +# -- Options for HTML output --------------------------------------------------- + +sys.path.append(os.path.abspath('_themes')) +html_theme_path = ['_themes'] + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# html_theme = 'flask' +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = { + 'logo': '_static/delta-chat.svg', + 'font_size': "1.1em", + 'caption_font_size': "0.9em", + 'code_font_size': "1.1em", + + +} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = ["_themes"] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +html_logo = "_static/delta-chat.svg" + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +html_favicon = '_static/favicon.ico' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +html_sidebars = { + 'index': [ + 'sidebarintro.html', + 'globaltoc.html', + 'searchbox.html' + ], + '**': [ + 'sidebarintro.html', + 'globaltoc.html', + 'relations.html', + 'searchbox.html' + ] +} + + + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +html_show_sourcelink = False + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +html_show_sphinx = False + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +html_use_opensearch = 'https://doc.devpi.net' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'deltachat-python' + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +'pointsize': '12pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'devpi.tex', u'deltachat documentation', + u'holger krekel', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'deltachat', u'deltachat documentation', + [u'holger krekel'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'devpi', u'devpi Documentation', + u'holger krekel', 'devpi', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/': None} + +# autodoc options +autodoc_member_order = "bysource" +# always document __init__ functions +def skip(app, what, name, obj, skip, options): + import attr + if name == "__init__": + if not hasattr(obj.im_class, "__attrs_attrs__"): + return False + return skip + +def setup(app): + app.connect("autodoc-skip-member", skip) + diff --git a/python/doc/examples.rst b/python/doc/examples.rst new file mode 100644 index 000000000..d2cc19f6a --- /dev/null +++ b/python/doc/examples.rst @@ -0,0 +1,38 @@ + + +examples +======== + + +Playing around on the commandline +---------------------------------- + +Once you have :doc:`installed deltachat bindings ` +you can start playing from the python interpreter commandline:: + +For example you can type ``python`` and then:: + + # instantiate and configure deltachat account + import deltachat + ac = deltachat.Account("/tmp/db") + + # start configuration activity and smtp/imap threads + ac.start_threads() + ac.configure(addr="test2@hq5.merlinux.eu", mail_pw="********") + + # create a contact and send a message + contact = ac.create_contact("someother@email.address") + chat = ac.create_chat_by_contact(contact) + chat.send_text_message("hi from the python interpreter command line") + +Checkout our :doc:`api` for the various high-level things you can do +to send/receive messages, create contacts and chats. + + +Looking at a real example +------------------------- + +The `deltabot repository `_ +contains a real-life example of Python bindings usage. + + diff --git a/python/doc/index.rst b/python/doc/index.rst new file mode 100644 index 000000000..3473623c7 --- /dev/null +++ b/python/doc/index.rst @@ -0,0 +1,39 @@ +deltachat python bindings +========================= + +The ``deltachat`` Python package provides two bindings for the core C-library +of the https://delta.chat messaging ecosystem: + +- :doc:`capi` is a lowlevel CFFI-binding to the + `deltachat-core C-API `_. + +- :doc:`api` [work-in-progress] is a high level interface to deltachat-core which aims + to be memory safe and thoroughly tested through continous tox/pytest runs. + + +getting started +--------------- + +.. toctree:: + :maxdepth: 2 + + install + examples + +.. toctree:: + :hidden: + + links + changelog + api + capi + lapi + +.. + Indices and tables + ================== + + * :ref:`genindex` + * :ref:`modindex` + * :ref:`search` + diff --git a/python/doc/install.rst b/python/doc/install.rst new file mode 100644 index 000000000..04abf49af --- /dev/null +++ b/python/doc/install.rst @@ -0,0 +1,2 @@ + +.. include:: ../README.rst diff --git a/python/doc/lapi.rst b/python/doc/lapi.rst new file mode 100644 index 000000000..95e28fd33 --- /dev/null +++ b/python/doc/lapi.rst @@ -0,0 +1,10 @@ + +low level API reference +=================================== + +for full C-docs, defines and function checkout :doc:`capi` + + +.. automodule:: deltachat.capi.lib + :members: + diff --git a/python/doc/links.rst b/python/doc/links.rst new file mode 100644 index 000000000..432775e1e --- /dev/null +++ b/python/doc/links.rst @@ -0,0 +1,11 @@ + +links +================================ + +.. _`deltachat`: https://delta.chat +.. _`deltachat-core repo`: https://github.com/deltachat +.. _pip: http://pypi.org/project/pip/ +.. _virtualenv: http://pypi.org/project/virtualenv/ +.. _merlinux: http://merlinux.eu +.. _pypi: http://pypi.org/ +.. _`issue-tracker`: https://github.com/deltachat/deltachat-core diff --git a/python/doc/make.bat b/python/doc/make.bat new file mode 100644 index 000000000..1a48d1e63 --- /dev/null +++ b/python/doc/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\devpi.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\devpi.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/python/install_py_bindings.sh b/python/install_py_bindings.sh new file mode 100755 index 000000000..466b146ef --- /dev/null +++ b/python/install_py_bindings.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -ex + +#cd .. +#cargo build -p deltachat_ffi --release +#cd python + +export CFLAGS=-I`realpath ../deltachat-ffi` +# the followine line results in "libdeltachat.so" not found +# export LDFLAGS='-Wl,-rpath=$ORIGIN/../target/release -Wl,--enable-new-dtags' +pip install -e . +export LD_LIBRARY_PATH=`realpath ../target/release` +python -c "import deltachat" diff --git a/python/setup.cfg b/python/setup.cfg new file mode 100644 index 000000000..4744402fc --- /dev/null +++ b/python/setup.cfg @@ -0,0 +1,4 @@ +[devpi:upload] +formats = sdist.tgz +no-vcs = 1 + diff --git a/python/setup.py b/python/setup.py new file mode 100644 index 000000000..fa1b81737 --- /dev/null +++ b/python/setup.py @@ -0,0 +1,44 @@ +import setuptools +import os +import re + + +def main(): + long_description, version = read_meta() + setuptools.setup( + name='deltachat', + version=version, + description='Python bindings for deltachat-core using CFFI', + long_description=long_description, + author='holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors', + setup_requires=['cffi>=1.0.0'], + install_requires=['cffi>=1.0.0', 'requests', 'attrs', 'six'], + packages=setuptools.find_packages('src'), + package_dir={'': 'src'}, + cffi_modules=['src/deltachat/_build.py:ffibuilder'], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', + 'Programming Language :: Python :: 3', + 'Topic :: Communications :: Email', + 'Topic :: Software Development :: Libraries', + ], + ) + + +def read_meta(): + with open(os.path.join("src", "deltachat", "__init__.py")) as f: + for line in f: + m = re.match('__version__ = "(\S*).*"', line) + if m: + version, = m.groups() + break + + with open("README.rst") as f: + long_desc = f.read() + return long_desc, version + + +if __name__ == "__main__": + main() diff --git a/python/src/deltachat/__init__.py b/python/src/deltachat/__init__.py new file mode 100644 index 000000000..fc4dc0153 --- /dev/null +++ b/python/src/deltachat/__init__.py @@ -0,0 +1,64 @@ +from deltachat import capi, const +from deltachat.capi import ffi +from deltachat.account import Account # noqa + +__version__ = "0.10.0dev1" + + +_DC_CALLBACK_MAP = {} + + +@capi.ffi.def_extern() +def py_dc_callback(ctx, evt, data1, data2): + """The global event handler. + + CFFI only allows us to set one global event handler, so this one + looks up the correct event handler for the given context. + """ + try: + callback = _DC_CALLBACK_MAP.get(ctx, lambda *a: 0) + except AttributeError: + # we are in a deep in GC-free/interpreter shutdown land + # nothing much better to do here than: + return 0 + + # the following code relates to the deltachat/_build.py's helper + # function which provides us signature info of an event call + evt_name = get_dc_event_name(evt) + event_sig_types = capi.lib.dc_get_event_signature_types(evt) + if data1 and event_sig_types & 1: + data1 = ffi.string(ffi.cast('char*', data1)).decode("utf8") + if data2 and event_sig_types & 2: + try: + data2 = ffi.string(ffi.cast('char*', data2)).decode("utf8") + except UnicodeDecodeError: + # XXX ignoring this error is not quite correct but for now + # i don't want to hunt down encoding problems in the c lib + data2 = ffi.string(ffi.cast('char*', data2)) + + try: + ret = callback(ctx, evt_name, data1, data2) + if event_sig_types & 4: + return ffi.cast('uintptr_t', ret) + elif event_sig_types & 8: + return ffi.cast('int', ret) + except: # noqa + raise + ret = 0 + return ret + + +def set_context_callback(dc_context, func): + _DC_CALLBACK_MAP[dc_context] = func + + +def clear_context_callback(dc_context): + _DC_CALLBACK_MAP.pop(dc_context, None) + + +def get_dc_event_name(integer, _DC_EVENTNAME_MAP={}): + if not _DC_EVENTNAME_MAP: + for name, val in vars(const).items(): + if name.startswith("DC_EVENT_"): + _DC_EVENTNAME_MAP[val] = name + return _DC_EVENTNAME_MAP[integer] diff --git a/python/src/deltachat/_build.py b/python/src/deltachat/_build.py new file mode 100644 index 000000000..880dc35d3 --- /dev/null +++ b/python/src/deltachat/_build.py @@ -0,0 +1,73 @@ +import distutils.ccompiler +import distutils.sysconfig +import tempfile +import os +import cffi + +# XXX hack out the header and library dirs +# relying on CFLAGS and LD_LIBRARY_PATH being set properly is not good +# (but we also don't want to rely on global installs of headers and libs) +HEADERDIR = os.environ["CFLAGS"].split("-I", 1)[1] +LIBDIR = os.environ["LD_LIBRARY_PATH"] + + +def ffibuilder(): + builder = cffi.FFI() + builder.set_source( + 'deltachat.capi', + """ + #include + const char * dupstring_helper(const char* string) + { + return strdup(string); + } + int dc_get_event_signature_types(int e) + { + int result = 0; + if (DC_EVENT_DATA1_IS_STRING(e)) + result |= 1; + if (DC_EVENT_DATA2_IS_STRING(e)) + result |= 2; + if (DC_EVENT_RETURNS_STRING(e)) + result |= 4; + if (DC_EVENT_RETURNS_INT(e)) + result |= 8; + return result; + } + """, + libraries=['deltachat'], + include_dirs=[HEADERDIR], + library_dirs=[LIBDIR], + ) + builder.cdef(""" + typedef int... time_t; + void free(void *ptr); + extern const char * dupstring_helper(const char* string); + extern int dc_get_event_signature_types(int); + """) + cc = distutils.ccompiler.new_compiler(force=True) + distutils.sysconfig.customize_compiler(cc) + with tempfile.NamedTemporaryFile(mode='w', suffix='.h') as src_fp: + src_fp.write('#include ') + src_fp.flush() + with tempfile.NamedTemporaryFile(mode='r') as dst_fp: + cc.preprocess(source=src_fp.name, + output_file=dst_fp.name, + include_dirs=[HEADERDIR], + macros=[('PY_CFFI', '1')]) + builder.cdef(dst_fp.read()) + builder.cdef(""" + extern "Python" uintptr_t py_dc_callback( + dc_context_t* context, + int event, + uintptr_t data1, + uintptr_t data2); + """) + return builder + + +if __name__ == '__main__': + import os.path + pkgdir = os.path.join(os.path.dirname(__file__), '..') + builder = ffibuilder() + builder.compile(tmpdir=pkgdir, verbose=True) diff --git a/python/src/deltachat/account.py b/python/src/deltachat/account.py new file mode 100644 index 000000000..e5ccb028b --- /dev/null +++ b/python/src/deltachat/account.py @@ -0,0 +1,429 @@ +""" Account class implementation. """ + +from __future__ import print_function +import threading +import re +import time +import requests +from array import array +try: + from queue import Queue +except ImportError: + from Queue import Queue +import attr +from attr import validators as v + +import deltachat +from . import const +from .capi import ffi, lib +from .cutil import as_dc_charpointer, from_dc_charpointer, iter_array +from .chatting import Contact, Chat, Message + + +class Account(object): + """ Each account is tied to a sqlite database file which is fully managed + by the underlying deltachat c-library. All public Account methods are + meant to be memory-safe and return memory-safe objects. + """ + def __init__(self, db_path, logid=None): + """ initialize account object. + + :param db_path: a path to the account database. The database + will be created if it doesn't exist. + :param logid: an optional logging prefix that should be used with + the default internal logging. + """ + self._dc_context = ffi.gc( + lib.dc_context_new(lib.py_dc_callback, ffi.NULL, ffi.NULL), + _destroy_dc_context, + ) + if hasattr(db_path, "encode"): + db_path = db_path.encode("utf8") + if not lib.dc_open(self._dc_context, db_path, ffi.NULL): + raise ValueError("Could not dc_open: {}".format(db_path)) + self._evhandler = EventHandler(self._dc_context) + self._evlogger = EventLogger(self._dc_context, logid) + deltachat.set_context_callback(self._dc_context, self._process_event) + self._threads = IOThreads(self._dc_context) + self._configkeys = self.get_config("sys.config_keys").split() + + def _check_config_key(self, name): + if name not in self._configkeys: + raise KeyError("{!r} not a valid config key, existing keys: {!r}".format( + name, self._configkeys)) + + def get_info(self): + """ return dictionary of built config parameters. """ + lines = from_dc_charpointer(lib.dc_get_info(self._dc_context)) + d = {} + for line in lines.split("\n"): + if not line.strip(): + continue + key, value = line.split("=", 1) + d[key.lower()] = value + return d + + def set_config(self, name, value): + """ set configuration values. + + :param name: config key name (unicode) + :param value: value to set (unicode) + :returns: None + """ + self._check_config_key(name) + name = name.encode("utf8") + value = value.encode("utf8") + if name == b"addr" and self.is_configured(): + raise ValueError("can not change 'addr' after account is configured.") + lib.dc_set_config(self._dc_context, name, value) + + def get_config(self, name): + """ return unicode string value. + + :param name: configuration key to lookup (eg "addr" or "mail_pw") + :returns: unicode value + :raises: KeyError if no config value was found. + """ + if name != "sys.config_keys": + self._check_config_key(name) + name = name.encode("utf8") + res = lib.dc_get_config(self._dc_context, name) + assert res != ffi.NULL, "config value not found for: {!r}".format(name) + return from_dc_charpointer(res) + + def configure(self, **kwargs): + """ set config values and configure this account. + + :param kwargs: name=value config settings for this account. + values need to be unicode. + :returns: None + """ + for name, value in kwargs.items(): + self.set_config(name, value) + lib.dc_configure(self._dc_context) + + def is_configured(self): + """ determine if the account is configured already; an initial connection + to SMTP/IMAP has been verified. + + :returns: True if account is configured. + """ + return lib.dc_is_configured(self._dc_context) + + def check_is_configured(self): + """ Raise ValueError if this account is not configured. """ + if not self.is_configured(): + raise ValueError("need to configure first") + + def get_infostring(self): + """ return info of the configured account. """ + self.check_is_configured() + return from_dc_charpointer(lib.dc_get_info(self._dc_context)) + + def get_blobdir(self): + """ return the directory for files. + + All sent files are copied to this directory if necessary. + Place files there directly to avoid copying. + """ + return from_dc_charpointer(lib.dc_get_blobdir(self._dc_context)) + + def get_self_contact(self): + """ return this account's identity as a :class:`deltachat.chatting.Contact`. + + :returns: :class:`deltachat.chatting.Contact` + """ + self.check_is_configured() + return Contact(self._dc_context, const.DC_CONTACT_ID_SELF) + + def create_message(self, view_type): + """ create a new non persistent message. + + :param view_type: a string specifying "text", "video", + "image", "audio" or "file". + :returns: :class:`deltachat.message.Message` instance. + """ + return Message.new(self._dc_context, view_type) + + def create_contact(self, email, name=None): + """ create a (new) Contact. If there already is a Contact + with that e-mail address, it is unblocked and its name is + updated. + + :param email: email-address (text type) + :param name: display name for this contact (optional) + :returns: :class:`deltachat.chatting.Contact` instance. + """ + name = as_dc_charpointer(name) + email = as_dc_charpointer(email) + contact_id = lib.dc_create_contact(self._dc_context, name, email) + assert contact_id > const.DC_CHAT_ID_LAST_SPECIAL + return Contact(self._dc_context, contact_id) + + def get_contacts(self, query=None, with_self=False, only_verified=False): + """ get a (filtered) list of contacts. + + :param query: if a string is specified, only return contacts + whose name or e-mail matches query. + :param only_verified: if true only return verified contacts. + :param with_self: if true the self-contact is also returned. + :returns: list of :class:`deltachat.message.Message` objects. + """ + flags = 0 + query = as_dc_charpointer(query) + if only_verified: + flags |= const.DC_GCL_VERIFIED_ONLY + if with_self: + flags |= const.DC_GCL_ADD_SELF + dc_array = ffi.gc( + lib.dc_get_contacts(self._dc_context, flags, query), + lib.dc_array_unref + ) + return list(iter_array(dc_array, lambda x: Contact(self._dc_context, x))) + + def create_chat_by_contact(self, contact): + """ create or get an existing 1:1 chat object for the specified contact. + + :param contact: chat_id (int) or contact object. + :returns: a :class:`deltachat.chatting.Chat` object. + """ + contact_id = getattr(contact, "id", contact) + assert isinstance(contact_id, int) + chat_id = lib.dc_create_chat_by_contact_id( + self._dc_context, contact_id) + return Chat(self._dc_context, chat_id) + + def create_chat_by_message(self, message): + """ create or get an existing chat object for the + the specified message. + + :param message: messsage id or message instance. + :returns: a :class:`deltachat.chatting.Chat` object. + """ + msg_id = getattr(message, "id", message) + assert isinstance(msg_id, int) + chat_id = lib.dc_create_chat_by_msg_id(self._dc_context, msg_id) + return Chat(self._dc_context, chat_id) + + def create_group_chat(self, name, verified=False): + """ create a new group chat object. + + Chats are unpromoted until the first message is sent. + + :param verified: if true only verified contacts can be added. + :returns: a :class:`deltachat.chatting.Chat` object. + """ + bytes_name = name.encode("utf8") + chat_id = lib.dc_create_group_chat(self._dc_context, verified, bytes_name) + return Chat(self._dc_context, chat_id) + + def get_chats(self): + """ return list of chats. + + :returns: a list of :class:`deltachat.chatting.Chat` objects. + """ + dc_chatlist = ffi.gc( + lib.dc_get_chatlist(self._dc_context, 0, ffi.NULL, 0), + lib.dc_chatlist_unref + ) + + assert dc_chatlist != ffi.NULL + chatlist = [] + for i in range(0, lib.dc_chatlist_get_cnt(dc_chatlist)): + chat_id = lib.dc_chatlist_get_chat_id(dc_chatlist, i) + chatlist.append(Chat(self._dc_context, chat_id)) + return chatlist + + def get_deaddrop_chat(self): + return Chat(self._dc_context, const.DC_CHAT_ID_DEADDROP) + + def get_message_by_id(self, msg_id): + """ return Message instance. """ + return Message.from_db(self._dc_context, msg_id) + + def mark_seen_messages(self, messages): + """ mark the given set of messages as seen. + + :param messages: a list of message ids or Message instances. + """ + arr = array("i") + for msg in messages: + msg = getattr(msg, "id", msg) + arr.append(msg) + msg_ids = ffi.cast("uint32_t*", ffi.from_buffer(arr)) + lib.dc_markseen_msgs(self._dc_context, msg_ids, len(messages)) + + def forward_messages(self, messages, chat): + """ Forward list of messages to a chat. + + :param messages: list of :class:`deltachat.message.Message` object. + :param chat: :class:`deltachat.chatting.Chat` object. + :returns: None + """ + msg_ids = [msg.id for msg in messages] + lib.dc_forward_msgs(self._dc_context, msg_ids, len(msg_ids), chat.id) + + def delete_messages(self, messages): + """ delete messages (local and remote). + + :param messages: list of :class:`deltachat.message.Message` object. + :returns: None + """ + msg_ids = [msg.id for msg in messages] + lib.dc_delete_msgs(self._dc_context, msg_ids, len(msg_ids)) + + def start_threads(self): + """ start IMAP/SMTP threads (and configure account if it hasn't happened). + + :raises: ValueError if 'addr' or 'mail_pw' are not configured. + :returns: None + """ + if not self.is_configured(): + self.configure() + self._threads.start() + + def stop_threads(self): + """ stop IMAP/SMTP threads. """ + self._threads.stop(wait=True) + + def _process_event(self, ctx, evt_name, data1, data2): + assert ctx == self._dc_context + self._evlogger(evt_name, data1, data2) + method = getattr(self._evhandler, evt_name.lower(), None) + if method is not None: + return method(data1, data2) or 0 + return 0 + + +class IOThreads: + def __init__(self, dc_context): + self._dc_context = dc_context + self._thread_quitflag = False + self._name2thread = {} + + def start(self, imap=True, smtp=True): + assert not self._name2thread + if imap: + self._start_one_thread("imap", self.imap_thread_run) + if smtp: + self._start_one_thread("smtp", self.smtp_thread_run) + + def _start_one_thread(self, name, func): + self._name2thread[name] = t = threading.Thread(target=func, name=name) + t.setDaemon(1) + t.start() + + def stop(self, wait=False): + self._thread_quitflag = True + lib.dc_interrupt_imap_idle(self._dc_context) + lib.dc_interrupt_smtp_idle(self._dc_context) + if wait: + for name, thread in self._name2thread.items(): + thread.join() + + def imap_thread_run(self): + print ("starting imap thread") + while not self._thread_quitflag: + lib.dc_perform_imap_jobs(self._dc_context) + lib.dc_perform_imap_fetch(self._dc_context) + lib.dc_perform_imap_idle(self._dc_context) + + def smtp_thread_run(self): + print ("starting smtp thread") + while not self._thread_quitflag: + lib.dc_perform_smtp_jobs(self._dc_context) + lib.dc_perform_smtp_idle(self._dc_context) + + +@attr.s +class EventHandler(object): + _dc_context = attr.ib(validator=v.instance_of(ffi.CData)) + + def read_url(self, url): + try: + r = requests.get(url) + except requests.ConnectionError: + return '' + else: + return r.content + + def dc_event_http_get(self, data1, data2): + url = data1 + content = self.read_url(url) + if not isinstance(content, bytes): + content = content.encode("utf8") + # we need to return a fresh pointer that the core owns + return lib.dupstring_helper(content) + + def dc_event_is_offline(self, data1, data2): + return 0 # always online + + +class EventLogger: + _loglock = threading.RLock() + + def __init__(self, dc_context, logid=None, debug=True): + self._dc_context = dc_context + self._event_queue = Queue() + self._debug = debug + if logid is None: + logid = str(self._dc_context).strip(">").split()[-1] + self.logid = logid + self._timeout = None + self.init_time = time.time() + + def __call__(self, evt_name, data1, data2): + self._log_event(evt_name, data1, data2) + self._event_queue.put((evt_name, data1, data2)) + + def set_timeout(self, timeout): + self._timeout = timeout + + def get(self, timeout=None, check_error=True): + timeout = timeout or self._timeout + ev = self._event_queue.get(timeout=timeout) + if check_error and ev[0] == "DC_EVENT_ERROR": + raise ValueError("{}({!r},{!r})".format(*ev)) + return ev + + def get_matching(self, event_name_regex): + self._log("-- waiting for event with regex: {} --".format(event_name_regex)) + rex = re.compile("(?:{}).*".format(event_name_regex)) + while 1: + ev = self.get() + if rex.match(ev[0]): + return ev + + def get_info_matching(self, regex): + rex = re.compile("(?:{}).*".format(regex)) + while 1: + ev = self.get_matching("DC_EVENT_INFO") + if rex.match(ev[2]): + return ev + + def _log_event(self, evt_name, data1, data2): + # don't show events that are anyway empty impls now + if evt_name == "DC_EVENT_GET_STRING": + return + if self._debug: + evpart = "{}({!r},{!r})".format(evt_name, data1, data2) + self._log(evpart) + + def _log(self, msg): + t = threading.currentThread() + tname = getattr(t, "name", t) + if tname == "MainThread": + tname = "MAIN" + with self._loglock: + print("{:2.2f} [{}-{}] {}".format(time.time() - self.init_time, tname, self.logid, msg)) + + +def _destroy_dc_context(dc_context, dc_context_unref=lib.dc_context_unref): + # destructor for dc_context + dc_context_unref(dc_context) + try: + deltachat.clear_context_callback(dc_context) + except (TypeError, AttributeError): + # we are deep into Python Interpreter shutdown, + # so no need to clear the callback context mapping. + pass diff --git a/python/src/deltachat/chatting.py b/python/src/deltachat/chatting.py new file mode 100644 index 000000000..5f65662ab --- /dev/null +++ b/python/src/deltachat/chatting.py @@ -0,0 +1,258 @@ +""" chatting related objects: Contact, Chat, Message. """ + +import os + +from . import props +from .cutil import as_dc_charpointer, from_dc_charpointer, iter_array +from .capi import lib, ffi +from . import const +import attr +from attr import validators as v +from .message import Message + + +@attr.s +class Contact(object): + """ Delta-Chat Contact. + + You obtain instances of it through :class:`deltachat.account.Account`. + """ + _dc_context = attr.ib(validator=v.instance_of(ffi.CData)) + id = attr.ib(validator=v.instance_of(int)) + + @property + def _dc_contact(self): + return ffi.gc( + lib.dc_get_contact(self._dc_context, self.id), + lib.dc_contact_unref + ) + + @props.with_doc + def addr(self): + """ normalized e-mail address for this account. """ + return from_dc_charpointer(lib.dc_contact_get_addr(self._dc_contact)) + + @props.with_doc + def display_name(self): + """ display name for this contact. """ + return from_dc_charpointer(lib.dc_contact_get_display_name(self._dc_contact)) + + def is_blocked(self): + """ Return True if the contact is blocked. """ + return lib.dc_contact_is_blocked(self._dc_contact) + + def is_verified(self): + """ Return True if the contact is verified. """ + return lib.dc_contact_is_verified(self._dc_contact) + + +@attr.s +class Chat(object): + """ Chat object which manages members and through which you can send and retrieve messages. + + You obtain instances of it through :class:`deltachat.account.Account`. + """ + _dc_context = attr.ib(validator=v.instance_of(ffi.CData)) + id = attr.ib(validator=v.instance_of(int)) + + @property + def _dc_chat(self): + return ffi.gc( + lib.dc_get_chat(self._dc_context, self.id), + lib.dc_chat_unref + ) + + def delete(self): + """Delete this chat and all its messages. + + Note: + + - does not delete messages on server + - the chat or contact is not blocked, new message will arrive + """ + lib.dc_delete_chat(self._dc_context, self.id) + + # ------ chat status/metadata API ------------------------------ + + def is_deaddrop(self): + """ return true if this chat is a deaddrop chat. + + :returns: True if chat is the deaddrop chat, False otherwise. + """ + return self.id == const.DC_CHAT_ID_DEADDROP + + def is_promoted(self): + """ return True if this chat is promoted, i.e. + the member contacts are aware of their membership, + have been sent messages. + + :returns: True if chat is promoted, False otherwise. + """ + return not lib.dc_chat_is_unpromoted(self._dc_chat) + + def get_name(self): + """ return name of this chat. + + :returns: unicode name + """ + return from_dc_charpointer(lib.dc_chat_get_name(self._dc_chat)) + + def set_name(self, name): + """ set name of this chat. + + :param: name as a unicode string. + :returns: None + """ + name = as_dc_charpointer(name) + return lib.dc_set_chat_name(self._dc_context, self.id, name) + + def get_type(self): + """ return type of this chat. + + :returns: one of const.DC_CHAT_TYPE_* + """ + return lib.dc_chat_get_type(self._dc_chat) + + # ------ chat messaging API ------------------------------ + + def send_text(self, text): + """ send a text message and return the resulting Message instance. + + :param msg: unicode text + :raises ValueError: if message can not be send/chat does not exist. + :returns: the resulting :class:`deltachat.message.Message` instance + """ + msg = as_dc_charpointer(text) + msg_id = lib.dc_send_text_msg(self._dc_context, self.id, msg) + if msg_id == 0: + raise ValueError("message could not be send, does chat exist?") + return Message.from_db(self._dc_context, msg_id) + + def send_file(self, path, mime_type="application/octet-stream"): + """ send a file and return the resulting Message instance. + + :param path: path to the file. + :param mime_type: the mime-type of this file, defaults to application/octet-stream. + :raises ValueError: if message can not be send/chat does not exist. + :returns: the resulting :class:`deltachat.message.Message` instance + """ + path = as_dc_charpointer(path) + mtype = as_dc_charpointer(mime_type) + msg = Message.new(self._dc_context, "file") + msg.set_file(path, mtype) + msg_id = lib.dc_send_msg(self._dc_context, self.id, msg._dc_msg) + if msg_id == 0: + raise ValueError("message could not be send, does chat exist?") + return Message.from_db(self._dc_context, msg_id) + + def send_image(self, path): + """ send an image message and return the resulting Message instance. + + :param path: path to an image file. + :raises ValueError: if message can not be send/chat does not exist. + :returns: the resulting :class:`deltachat.message.Message` instance + """ + if not os.path.exists(path): + raise ValueError("path does not exist: {!r}".format(path)) + msg = Message.new(self._dc_context, "image") + msg.set_file(path) + msg_id = lib.dc_send_msg(self._dc_context, self.id, msg._dc_msg) + return Message.from_db(self._dc_context, msg_id) + + def prepare_file(self, path, mime_type=None, view_type="file"): + """ prepare a message for sending and return the resulting Message instance. + + To actually send the message, call :meth:`send_prepared`. + The file must be inside the blob directory. + + :param path: path to the file. + :param mime_type: the mime-type of this file, defaults to auto-detection. + :param view_type: passed to :meth:`MessageType.new`. + :raises ValueError: if message can not be prepared/chat does not exist. + :returns: the resulting :class:`Message` instance + """ + path = as_dc_charpointer(path) + mtype = as_dc_charpointer(mime_type) + msg = Message.new(self._dc_context, view_type) + msg.set_file(path, mtype) + msg_id = lib.dc_prepare_msg(self._dc_context, self.id, msg._dc_msg) + if msg_id == 0: + raise ValueError("message could not be prepared, does chat exist?") + return Message.from_db(self._dc_context, msg_id) + + def send_prepared(self, message): + """ send a previously prepared message. + + :param message: a :class:`Message` instance previously returned by + :meth:`prepare_file`. + :raises ValueError: if message can not be sent. + :returns: a :class:`deltachat.message.Message` instance with updated state + """ + msg_id = lib.dc_send_msg(self._dc_context, 0, message._dc_msg) + if msg_id == 0: + raise ValueError("message could not be sent") + return Message.from_db(self._dc_context, msg_id) + + def get_messages(self): + """ return list of messages in this chat. + + :returns: list of :class:`deltachat.message.Message` objects for this chat. + """ + dc_array = ffi.gc( + lib.dc_get_chat_msgs(self._dc_context, self.id, 0, 0), + lib.dc_array_unref + ) + return list(iter_array(dc_array, lambda x: Message.from_db(self._dc_context, x))) + + def count_fresh_messages(self): + """ return number of fresh messages in this chat. + + :returns: number of fresh messages + """ + return lib.dc_get_fresh_msg_cnt(self._dc_context, self.id) + + def mark_noticed(self): + """ mark all messages in this chat as noticed. + + Noticed messages are no longer fresh. + """ + return lib.dc_marknoticed_chat(self._dc_context, self.id) + + # ------ group management API ------------------------------ + + def add_contact(self, contact): + """ add a contact to this chat. + + :params: contact object. + :raises ValueError: if contact could not be added + :returns: None + """ + ret = lib.dc_add_contact_to_chat(self._dc_context, self.id, contact.id) + if ret != 1: + raise ValueError("could not add contact {!r} to chat".format(contact)) + + def remove_contact(self, contact): + """ remove a contact from this chat. + + :params: contact object. + :raises ValueError: if contact could not be removed + :returns: None + """ + ret = lib.dc_remove_contact_from_chat(self._dc_context, self.id, contact.id) + if ret != 1: + raise ValueError("could not remove contact {!r} from chat".format(contact)) + + def get_contacts(self): + """ get all contacts for this chat. + :params: contact object. + :raises ValueError: if contact could not be added + :returns: list of :class:`deltachat.chatting.Contact` objects for this chat + + """ + dc_array = ffi.gc( + lib.dc_get_chat_contacts(self._dc_context, self.id), + lib.dc_array_unref + ) + return list(iter_array( + dc_array, lambda id: Contact(self._dc_context, id)) + ) diff --git a/python/src/deltachat/const.py b/python/src/deltachat/const.py new file mode 100644 index 000000000..b68a990d8 --- /dev/null +++ b/python/src/deltachat/const.py @@ -0,0 +1,113 @@ +import sys +import re +import os +from os.path import dirname, abspath +from os.path import join as joinpath + +# the following const are generated from deltachat.h +# this works well when you in a git-checkout +# run "python deltachat/const.py" to regenerate events +# begin const generated +DC_GCL_ARCHIVED_ONLY = 0x01 +DC_GCL_NO_SPECIALS = 0x02 +DC_GCL_ADD_ALLDONE_HINT = 0x04 +DC_GCL_VERIFIED_ONLY = 0x01 +DC_GCL_ADD_SELF = 0x02 +DC_CHAT_ID_DEADDROP = 1 +DC_CHAT_ID_TRASH = 3 +DC_CHAT_ID_MSGS_IN_CREATION = 4 +DC_CHAT_ID_STARRED = 5 +DC_CHAT_ID_ARCHIVED_LINK = 6 +DC_CHAT_ID_ALLDONE_HINT = 7 +DC_CHAT_ID_LAST_SPECIAL = 9 +DC_CHAT_TYPE_UNDEFINED = 0 +DC_CHAT_TYPE_SINGLE = 100 +DC_CHAT_TYPE_GROUP = 120 +DC_CHAT_TYPE_VERIFIED_GROUP = 130 +DC_MSG_ID_MARKER1 = 1 +DC_MSG_ID_DAYMARKER = 9 +DC_MSG_ID_LAST_SPECIAL = 9 +DC_STATE_UNDEFINED = 0 +DC_STATE_IN_FRESH = 10 +DC_STATE_IN_NOTICED = 13 +DC_STATE_IN_SEEN = 16 +DC_STATE_OUT_PREPARING = 18 +DC_STATE_OUT_DRAFT = 19 +DC_STATE_OUT_PENDING = 20 +DC_STATE_OUT_FAILED = 24 +DC_STATE_OUT_DELIVERED = 26 +DC_STATE_OUT_MDN_RCVD = 28 +DC_CONTACT_ID_SELF = 1 +DC_CONTACT_ID_DEVICE = 2 +DC_CONTACT_ID_LAST_SPECIAL = 9 +DC_MSG_TEXT = 10 +DC_MSG_IMAGE = 20 +DC_MSG_GIF = 21 +DC_MSG_AUDIO = 40 +DC_MSG_VOICE = 41 +DC_MSG_VIDEO = 50 +DC_MSG_FILE = 60 +DC_EVENT_INFO = 100 +DC_EVENT_SMTP_CONNECTED = 101 +DC_EVENT_IMAP_CONNECTED = 102 +DC_EVENT_SMTP_MESSAGE_SENT = 103 +DC_EVENT_WARNING = 300 +DC_EVENT_ERROR = 400 +DC_EVENT_ERROR_NETWORK = 401 +DC_EVENT_ERROR_SELF_NOT_IN_GROUP = 410 +DC_EVENT_MSGS_CHANGED = 2000 +DC_EVENT_INCOMING_MSG = 2005 +DC_EVENT_MSG_DELIVERED = 2010 +DC_EVENT_MSG_FAILED = 2012 +DC_EVENT_MSG_READ = 2015 +DC_EVENT_CHAT_MODIFIED = 2020 +DC_EVENT_CONTACTS_CHANGED = 2030 +DC_EVENT_LOCATION_CHANGED = 2035 +DC_EVENT_CONFIGURE_PROGRESS = 2041 +DC_EVENT_IMEX_PROGRESS = 2051 +DC_EVENT_IMEX_FILE_WRITTEN = 2052 +DC_EVENT_SECUREJOIN_INVITER_PROGRESS = 2060 +DC_EVENT_SECUREJOIN_JOINER_PROGRESS = 2061 +DC_EVENT_GET_STRING = 2091 +DC_EVENT_HTTP_GET = 2100 +DC_EVENT_HTTP_POST = 2110 +DC_EVENT_FILE_COPIED = 2055 +DC_EVENT_IS_OFFLINE = 2081 +# end const generated + + +def read_event_defines(f): + rex = re.compile(r'#define\s+((?:DC_EVENT_|DC_MSG|DC_STATE_|DC_CONTACT_ID_|DC_GCL|DC_CHAT)\S+)\s+([x\d]+).*') + for line in f: + m = rex.match(line) + if m: + yield m.groups() + + +if __name__ == "__main__": + here = abspath(__file__).rstrip("oc") + here_dir = dirname(here) + if len(sys.argv) >= 2: + deltah = sys.argv[1] + else: + deltah = joinpath(dirname(dirname(dirname(here_dir))), "src", "deltachat.h") + assert os.path.exists(deltah) + + lines = [] + skip_to_end = False + for orig_line in open(here): + if skip_to_end: + if not orig_line.startswith("# end const"): + continue + skip_to_end = False + lines.append(orig_line) + if orig_line.startswith("# begin const"): + with open(deltah) as f: + for name, item in read_event_defines(f): + lines.append("{} = {}\n".format(name, item)) + skip_to_end = True + + tmpname = here + ".tmp" + with open(tmpname, "w") as f: + f.write("".join(lines)) + os.rename(tmpname, here) diff --git a/python/src/deltachat/cutil.py b/python/src/deltachat/cutil.py new file mode 100644 index 000000000..80c1f3abe --- /dev/null +++ b/python/src/deltachat/cutil.py @@ -0,0 +1,19 @@ +from .capi import lib +from .capi import ffi + + +def as_dc_charpointer(obj): + if obj == ffi.NULL or obj is None: + return ffi.NULL + if not isinstance(obj, bytes): + return obj.encode("utf8") + return obj + + +def iter_array(dc_array_t, constructor): + for i in range(0, lib.dc_array_get_cnt(dc_array_t)): + yield constructor(lib.dc_array_get_id(dc_array_t, i)) + + +def from_dc_charpointer(obj): + return ffi.string(obj).decode("utf8") diff --git a/python/src/deltachat/message.py b/python/src/deltachat/message.py new file mode 100644 index 000000000..4719e0b45 --- /dev/null +++ b/python/src/deltachat/message.py @@ -0,0 +1,262 @@ +""" chatting related objects: Contact, Chat, Message. """ + +import os +from . import props +from .cutil import from_dc_charpointer, as_dc_charpointer +from .capi import lib, ffi +from . import const +from datetime import datetime +import attr +from attr import validators as v + + +@attr.s +class Message(object): + """ Message object. + + You obtain instances of it through :class:`deltachat.account.Account` or + :class:`deltachat.chatting.Chat`. + """ + _dc_context = attr.ib(validator=v.instance_of(ffi.CData)) + try: + id = attr.ib(validator=v.instance_of((int, long))) + except NameError: # py35 + id = attr.ib(validator=v.instance_of(int)) + + @property + def _dc_msg(self): + if self.id > 0: + return ffi.gc( + lib.dc_get_msg(self._dc_context, self.id), + lib.dc_msg_unref + ) + return self._dc_msg_volatile + + @classmethod + def from_db(cls, _dc_context, id): + assert id > 0 + return cls(_dc_context, id) + + @classmethod + def new(cls, dc_context, view_type): + """ create a non-persistent method. """ + msg = cls(dc_context, 0) + view_type_code = MessageType.get_typecode(view_type) + msg._dc_msg_volatile = ffi.gc( + lib.dc_msg_new(dc_context, view_type_code), + lib.dc_msg_unref + ) + return msg + + def get_state(self): + """ get the message in/out state. + + :returns: :class:`deltachat.message.MessageState` + """ + return MessageState(self) + + @props.with_doc + def text(self): + """unicode text of this messages (might be empty if not a text message). """ + return from_dc_charpointer(lib.dc_msg_get_text(self._dc_msg)) + + def set_text(self, text): + """set text of this message. """ + return lib.dc_msg_set_text(self._dc_msg, as_dc_charpointer(text)) + + @props.with_doc + def filename(self): + """filename if there was an attachment, otherwise empty string. """ + return from_dc_charpointer(lib.dc_msg_get_file(self._dc_msg)) + + def set_file(self, path, mime_type=None): + """set file for this message. """ + mtype = ffi.NULL if mime_type is None else mime_type + assert os.path.exists(path) + lib.dc_msg_set_file(self._dc_msg, as_dc_charpointer(path), mtype) + + @props.with_doc + def basename(self): + """basename of the attachment if it exists, otherwise empty string. """ + return from_dc_charpointer(lib.dc_msg_get_filename(self._dc_msg)) + + @props.with_doc + def filemime(self): + """mime type of the file (if it exists)""" + return from_dc_charpointer(lib.dc_msg_get_filemime(self._dc_msg)) + + @props.with_doc + def view_type(self): + """the view type of this message. + + :returns: a :class:`deltachat.message.MessageType` instance. + """ + return MessageType(lib.dc_msg_get_viewtype(self._dc_msg)) + + @props.with_doc + def time_sent(self): + """UTC time when the message was sent. + + :returns: naive datetime.datetime() object. + """ + ts = lib.dc_msg_get_timestamp(self._dc_msg) + return datetime.utcfromtimestamp(ts) + + @props.with_doc + def time_received(self): + """UTC time when the message was received. + + :returns: naive datetime.datetime() object or None if message is an outgoing one. + """ + ts = lib.dc_msg_get_received_timestamp(self._dc_msg) + if ts: + return datetime.utcfromtimestamp(ts) + + def get_mime_headers(self): + """ return mime-header object for an incoming message. + + This only returns a non-None object if ``save_mime_headers`` + config option was set and the message is incoming. + + :returns: email-mime message object (with headers only, no body). + """ + import email.parser + mime_headers = lib.dc_get_mime_headers(self._dc_context, self.id) + if mime_headers: + s = ffi.string(mime_headers) + if isinstance(s, bytes): + s = s.decode("ascii") + return email.message_from_string(s) + + @property + def chat(self): + """chat this message was posted in. + + :returns: :class:`deltachat.chatting.Chat` object + """ + from .chatting import Chat + chat_id = lib.dc_msg_get_chat_id(self._dc_msg) + return Chat(self._dc_context, chat_id) + + def get_sender_contact(self): + """return the contact of who wrote the message. + + :returns: :class:`deltachat.chatting.Contact` instance + """ + from .chatting import Contact + contact_id = lib.dc_msg_get_from_id(self._dc_msg) + return Contact(self._dc_context, contact_id) + + +@attr.s +class MessageType(object): + """ DeltaChat message type, with is_* methods. """ + _type = attr.ib(validator=v.instance_of(int)) + _mapping = { + const.DC_MSG_TEXT: 'text', + const.DC_MSG_IMAGE: 'image', + const.DC_MSG_GIF: 'gif', + const.DC_MSG_AUDIO: 'audio', + const.DC_MSG_VIDEO: 'video', + const.DC_MSG_FILE: 'file' + } + + @classmethod + def get_typecode(cls, view_type): + for code, value in cls._mapping.items(): + if value == view_type: + return code + raise ValueError("message typecode not found for {!r}".format(view_type)) + + @props.with_doc + def name(self): + """ human readable type name. """ + return self._mapping.get(self._type, "") + + def is_text(self): + """ return True if it's a text message. """ + return self._type == const.DC_MSG_TEXT + + def is_image(self): + """ return True if it's an image message. """ + return self._type == const.DC_MSG_IMAGE + + def is_gif(self): + """ return True if it's a gif message. """ + return self._type == const.DC_MSG_GIF + + def is_audio(self): + """ return True if it's an audio message. """ + return self._type == const.DC_MSG_AUDIO + + def is_video(self): + """ return True if it's a video message. """ + return self._type == const.DC_MSG_VIDEO + + def is_file(self): + """ return True if it's a file message. """ + return self._type == const.DC_MSG_FILE + + +@attr.s +class MessageState(object): + """ Current Message In/Out state, updated on each call of is_* methods. + """ + message = attr.ib(validator=v.instance_of(Message)) + + @property + def _msgstate(self): + return lib.dc_msg_get_state(self.message._dc_msg) + + def is_in_fresh(self): + """ return True if Message is incoming fresh message (un-noticed). + + Fresh messages are not noticed nor seen and are typically + shown in notifications. + """ + return self._msgstate == const.DC_STATE_IN_FRESH + + def is_in_noticed(self): + """Return True if Message is incoming and noticed. + + Eg. chat opened but message not yet read - noticed messages + are not counted as unread but were not marked as read nor resulted in MDNs. + """ + return self._msgstate == const.DC_STATE_IN_NOTICED + + def is_in_seen(self): + """Return True if Message is incoming, noticed and has been seen. + + Eg. chat opened but message not yet read - noticed messages + are not counted as unread but were not marked as read nor resulted in MDNs. + """ + return self._msgstate == const.DC_STATE_IN_SEEN + + def is_out_preparing(self): + """Return True if Message is outgoing, but its file is being prepared. + """ + return self._msgstate == const.DC_STATE_OUT_PREPARING + + def is_out_pending(self): + """Return True if Message is outgoing, but is pending (no single checkmark). + """ + return self._msgstate == const.DC_STATE_OUT_PENDING + + def is_out_failed(self): + """Return True if Message is unrecoverably failed. + """ + return self._msgstate == const.DC_STATE_OUT_FAILED + + def is_out_delivered(self): + """Return True if Message was successfully delivered to the server (one checkmark). + + Note, that already delivered messages may get into the state is_out_failed(). + """ + return self._msgstate == const.DC_STATE_OUT_DELIVERED + + def is_out_mdn_received(self): + """Return True if message was marked as read by the recipient(s) (two checkmarks; + this requires goodwill on the receiver's side). If a sent message changes to this + state, you'll receive the event DC_EVENT_MSG_READ. + """ + return self._msgstate == const.DC_STATE_OUT_MDN_RCVD diff --git a/python/src/deltachat/props.py b/python/src/deltachat/props.py new file mode 100644 index 000000000..3190e6dd3 --- /dev/null +++ b/python/src/deltachat/props.py @@ -0,0 +1,30 @@ +"""Helpers for properties.""" + + +def with_doc(f): + return property(f, None, None, f.__doc__) + + +# copied over unmodified from +# https://github.com/devpi/devpi/blob/master/common/devpi_common/types.py +def cached(f): + """returns a cached property that is calculated by function f""" + def get(self): + try: + return self._property_cache[f] + except AttributeError: + self._property_cache = {} + except KeyError: + pass + x = self._property_cache[f] = f(self) + return x + + def set(self, val): + propcache = self.__dict__.setdefault("_property_cache", {}) + propcache[f] = val + + def fdel(self): + propcache = self.__dict__.setdefault("_property_cache", {}) + del propcache[f] + + return property(get, set, fdel) diff --git a/python/tests/auditwheels.py b/python/tests/auditwheels.py new file mode 100644 index 000000000..eb4f5055a --- /dev/null +++ b/python/tests/auditwheels.py @@ -0,0 +1,13 @@ + +import os +import sys +import subprocess + + +if __name__ == "__main__": + assert len(sys.argv) == 2 + workspacedir = sys.argv[1] + for relpath in os.listdir(workspacedir): + if relpath.startswith("deltachat"): + p = os.path.join(workspacedir, relpath) + subprocess.check_call(["auditwheel", "repair", p, "-w", workspacedir]) diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 000000000..4189b02a5 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,155 @@ +from __future__ import print_function +import os +import pytest +import time +from deltachat import Account +from deltachat import props +from deltachat.capi import lib +import tempfile + + +def pytest_addoption(parser): + parser.addoption( + "--liveconfig", action="store", default=None, + help="a file with >=2 lines where each line " + "contains NAME=VALUE config settings for one account" + ) + + +def pytest_report_header(config, startdir): + t = tempfile.mktemp() + try: + ac = Account(t) + info = ac.get_info() + del ac + finally: + os.remove(t) + return "Deltachat core={} sqlite={}".format( + info['deltachat_core_version'], + info['sqlite_version'], + ) + + +@pytest.fixture(scope="session") +def data(): + class Data: + def __init__(self): + self.path = os.path.join(os.path.dirname(__file__), "data") + + def get_path(self, bn): + fn = os.path.join(self.path, bn) + assert os.path.exists(fn) + return fn + return Data() + + +@pytest.fixture +def acfactory(pytestconfig, tmpdir, request): + fn = pytestconfig.getoption("--liveconfig") + + class AccountMaker: + def __init__(self): + self.live_count = 0 + self.offline_count = 0 + self._finalizers = [] + request.addfinalizer(self.finalize) + self.init_time = time.time() + + def finalize(self): + while self._finalizers: + fin = self._finalizers.pop() + fin() + + @props.cached + def configlist(self): + configlist = [] + for line in open(fn): + if line.strip(): + d = {} + for part in line.split(): + name, value = part.split("=") + d[name] = value + configlist.append(d) + return configlist + + def get_unconfigured_account(self): + self.offline_count += 1 + tmpdb = tmpdir.join("offlinedb%d" % self.offline_count) + ac = Account(tmpdb.strpath, logid="ac{}".format(self.offline_count)) + ac._evlogger.init_time = self.init_time + ac._evlogger.set_timeout(2) + return ac + + def get_configured_offline_account(self): + ac = self.get_unconfigured_account() + + # do a pseudo-configured account + addr = "addr{}@offline.org".format(self.offline_count) + ac.set_config("addr", addr) + lib.dc_set_config(ac._dc_context, b"configured_addr", addr.encode("ascii")) + ac.set_config("mail_pw", "123") + lib.dc_set_config(ac._dc_context, b"configured_mail_pw", b"123") + lib.dc_set_config(ac._dc_context, b"configured", b"1") + return ac + + def get_online_configuring_account(self): + if not fn: + pytest.skip("specify a --liveconfig file to run tests with real accounts") + self.live_count += 1 + configdict = self.configlist.pop(0) + tmpdb = tmpdir.join("livedb%d" % self.live_count) + ac = Account(tmpdb.strpath, logid="ac{}".format(self.live_count)) + ac._evlogger.init_time = self.init_time + ac._evlogger.set_timeout(30) + ac.configure(**configdict) + ac.start_threads() + self._finalizers.append(ac.stop_threads) + return ac + + return AccountMaker() + + +@pytest.fixture +def tmp_db_path(tmpdir): + return tmpdir.join("test.db").strpath + + +@pytest.fixture +def lp(): + class Printer: + def sec(self, msg): + print() + print("=" * 10, msg, "=" * 10) + + def step(self, msg): + print("-" * 5, "step " + msg, "-" * 5) + return Printer() + + +def wait_configuration_progress(account, target): + while 1: + evt_name, data1, data2 = \ + account._evlogger.get_matching("DC_EVENT_CONFIGURE_PROGRESS") + if data1 >= target: + print("** CONFIG PROGRESS {}".format(target), account) + break + + +def wait_successful_IMAP_SMTP_connection(account): + imap_ok = smtp_ok = False + while not imap_ok or not smtp_ok: + evt_name, data1, data2 = \ + account._evlogger.get_matching("DC_EVENT_(IMAP|SMTP)_CONNECTED") + if evt_name == "DC_EVENT_IMAP_CONNECTED": + imap_ok = True + if evt_name == "DC_EVENT_SMTP_CONNECTED": + smtp_ok = True + print("** IMAP and SMTP logins successful", account) + + +def wait_msgs_changed(account, chat_id, msg_id=None): + ev = account._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + assert ev[1] == chat_id + if msg_id is not None: + assert ev[2] == msg_id + return ev[2] diff --git a/python/tests/data/d.png b/python/tests/data/d.png new file mode 100644 index 000000000..bba6e0c63 Binary files /dev/null and b/python/tests/data/d.png differ diff --git a/python/tests/data/r.txt b/python/tests/data/r.txt new file mode 100644 index 000000000..3e23ae484 --- /dev/null +++ b/python/tests/data/r.txt @@ -0,0 +1,2 @@ + +hello diff --git a/python/tests/test_account.py b/python/tests/test_account.py new file mode 100644 index 000000000..7194b0401 --- /dev/null +++ b/python/tests/test_account.py @@ -0,0 +1,335 @@ +from __future__ import print_function +import pytest +import os +from deltachat import const +from datetime import datetime, timedelta +from conftest import wait_configuration_progress, wait_successful_IMAP_SMTP_connection + + +class TestOfflineAccount: + def test_getinfo(self, acfactory): + ac1 = acfactory.get_unconfigured_account() + d = ac1.get_info() + assert d["compile_date"] + assert d["arch"] + assert d["number_of_chats"] == "0" + + def test_is_not_configured(self, acfactory): + ac1 = acfactory.get_unconfigured_account() + assert not ac1.is_configured() + with pytest.raises(ValueError): + ac1.check_is_configured() + + def test_wrong_config_keys(self, acfactory): + ac1 = acfactory.get_unconfigured_account() + with pytest.raises(KeyError): + ac1.set_config("lqkwje", "value") + with pytest.raises(KeyError): + ac1.get_config("lqkwje") + + def test_has_savemime(self, acfactory): + ac1 = acfactory.get_unconfigured_account() + assert "save_mime_headers" in ac1.get_config("sys.config_keys").split() + + def test_selfcontact_if_unconfigured(self, acfactory): + ac1 = acfactory.get_unconfigured_account() + with pytest.raises(ValueError): + ac1.get_self_contact() + + def test_get_info(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + out = ac1.get_infostring() + assert "number_of_chats=0" in out + + def test_selfcontact_configured(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + me = ac1.get_self_contact() + assert me.display_name + assert me.addr + + def test_get_config_fails(self, acfactory): + ac1 = acfactory.get_unconfigured_account() + with pytest.raises(KeyError): + ac1.get_config("123123") + + def test_contact_attr(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact(email="some1@hello.com", name="some1") + assert contact1.id + assert contact1.addr == "some1@hello.com" + assert contact1.display_name == "some1" + assert not contact1.is_blocked() + assert not contact1.is_verified() + + def test_get_contacts(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact(email="some1@hello.com", name="some1") + contacts = ac1.get_contacts() + assert len(contacts) == 1 + assert contact1 in contacts + + assert not ac1.get_contacts(query="some2") + assert ac1.get_contacts(query="some1") + assert not ac1.get_contacts(only_verified=True) + contacts = ac1.get_contacts(with_self=True) + assert len(contacts) == 2 + + def test_chat(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + chat = ac1.create_chat_by_contact(contact1) + assert chat.id >= const.DC_CHAT_ID_LAST_SPECIAL, chat.id + + chat2 = ac1.create_chat_by_contact(contact1.id) + assert chat2.id == chat.id + assert chat2.get_name() == chat.get_name() + assert chat == chat2 + assert not (chat != chat2) + + for ichat in ac1.get_chats(): + if ichat.id == chat.id: + break + else: + pytest.fail("could not find chat") + + def test_group_chat_creation(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + contact2 = ac1.create_contact("some2@hello.com", name="some2") + chat = ac1.create_group_chat(name="title1") + chat.add_contact(contact1) + chat.add_contact(contact2) + assert chat.get_name() == "title1" + assert contact1 in chat.get_contacts() + assert contact2 in chat.get_contacts() + assert not chat.is_promoted() + chat.set_name("title2") + assert chat.get_name() == "title2" + + def test_delete_and_send_fails(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + chat = ac1.create_chat_by_contact(contact1) + chat.delete() + ac1._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + with pytest.raises(ValueError): + chat.send_text("msg1") + + def test_create_message(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + message = ac1.create_message("text") + assert message.id == 0 + assert message._dc_msg is message._dc_msg + message.set_text("hello") + assert message.text == "hello" + assert message.id == 0 + + def test_message(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + chat = ac1.create_chat_by_contact(contact1) + msg = chat.send_text("msg1") + assert msg + assert msg.view_type.is_text() + assert msg.view_type.name == "text" + assert not msg.view_type.is_audio() + assert not msg.view_type.is_video() + assert not msg.view_type.is_gif() + assert not msg.view_type.is_file() + assert not msg.view_type.is_image() + msg_state = msg.get_state() + assert not msg_state.is_in_fresh() + assert not msg_state.is_in_noticed() + assert not msg_state.is_in_seen() + assert msg_state.is_out_pending() + assert not msg_state.is_out_failed() + assert not msg_state.is_out_delivered() + assert not msg_state.is_out_mdn_received() + + def test_message_image(self, acfactory, data, lp): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + chat = ac1.create_chat_by_contact(contact1) + with pytest.raises(ValueError): + chat.send_image(path="notexists") + fn = data.get_path("d.png") + lp.sec("sending image") + msg = chat.send_image(fn) + assert msg.view_type.name == "image" + assert msg + assert msg.id > 0 + assert os.path.exists(msg.filename) + assert msg.filemime == "image/png" + + @pytest.mark.parametrize("typein,typeout", [ + (None, "application/octet-stream"), + ("text/plain", "text/plain"), + ("image/png", "image/png"), + ]) + def test_message_file(self, acfactory, data, lp, typein, typeout): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + chat = ac1.create_chat_by_contact(contact1) + lp.sec("sending file") + fn = data.get_path("r.txt") + msg = chat.send_file(fn, typein) + assert msg + assert msg.id > 0 + assert msg.view_type.name == "file" + assert msg.view_type.is_file() + assert os.path.exists(msg.filename) + assert msg.filename.endswith(msg.basename) + assert msg.filemime == typeout + + def test_chat_message_distinctions(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + contact1 = ac1.create_contact("some1@hello.com", name="some1") + chat = ac1.create_chat_by_contact(contact1) + past1s = datetime.utcnow() - timedelta(seconds=1) + msg = chat.send_text("msg1") + ts = msg.time_sent + assert msg.time_received is None + assert ts.strftime("Y") + assert past1s < ts + contact = msg.get_sender_contact() + assert contact == ac1.get_self_contact() + + def test_basic_configure_ok_addr_setting_forbidden(self, acfactory): + ac1 = acfactory.get_configured_offline_account() + assert ac1.get_config("mail_pw") + assert ac1.is_configured() + with pytest.raises(ValueError): + ac1.set_config("addr", "123@example.org") + with pytest.raises(ValueError): + ac1.configure(addr="123@example.org") + + +class TestOnlineAccount: + def test_forward_messages(self, acfactory): + ac1 = acfactory.get_online_configuring_account() + ac2 = acfactory.get_online_configuring_account() + c2 = ac1.create_contact(email=ac2.get_config("addr")) + chat = ac1.create_chat_by_contact(c2) + assert chat.id >= const.DC_CHAT_ID_LAST_SPECIAL + wait_successful_IMAP_SMTP_connection(ac1) + wait_configuration_progress(ac1, 1000) + wait_successful_IMAP_SMTP_connection(ac2) + wait_configuration_progress(ac2, 1000) + + msg_out = chat.send_text("message2") + + # wait for other account to receive + ev = ac2._evlogger.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED") + assert ev[2] == msg_out.id + msg_in = ac2.get_message_by_id(msg_out.id) + assert msg_in.text == "message2" + + # check the message arrived in contact-requests/deaddrop + chat2 = msg_in.chat + assert msg_in in chat2.get_messages() + assert chat2.is_deaddrop() + assert chat2 == ac2.get_deaddrop_chat() + chat3 = ac2.create_group_chat("newgroup") + assert not chat3.is_promoted() + ac2.forward_messages([msg_in], chat3) + assert chat3.is_promoted() + messages = chat3.get_messages() + ac2.delete_messages(messages) + assert not chat3.get_messages() + + def test_send_and_receive_message(self, acfactory, lp): + lp.sec("starting accounts, waiting for configuration") + ac1 = acfactory.get_online_configuring_account() + ac2 = acfactory.get_online_configuring_account() + c2 = ac1.create_contact(email=ac2.get_config("addr")) + chat = ac1.create_chat_by_contact(c2) + assert chat.id >= const.DC_CHAT_ID_LAST_SPECIAL + + wait_configuration_progress(ac1, 1000) + wait_configuration_progress(ac2, 1000) + + lp.sec("sending text message from ac1 to ac2") + msg_out = chat.send_text("message1") + ev = ac1._evlogger.get_matching("DC_EVENT_MSG_DELIVERED") + evt_name, data1, data2 = ev + assert data1 == chat.id + assert data2 == msg_out.id + assert msg_out.get_state().is_out_delivered() + + lp.sec("wait for ac2 to receive message") + ev = ac2._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + assert ev[2] == msg_out.id + msg_in = ac2.get_message_by_id(msg_out.id) + assert msg_in.text == "message1" + + lp.sec("check the message arrived in contact-requets/deaddrop") + chat2 = msg_in.chat + assert msg_in in chat2.get_messages() + assert chat2.is_deaddrop() + assert chat2.count_fresh_messages() == 0 + assert msg_in.time_received > msg_in.time_sent + + lp.sec("create new chat with contact and verify it's proper") + chat2b = ac2.create_chat_by_message(msg_in) + assert not chat2b.is_deaddrop() + assert chat2b.count_fresh_messages() == 1 + + lp.sec("mark chat as noticed") + chat2b.mark_noticed() + assert chat2b.count_fresh_messages() == 0 + + lp.sec("mark message as seen on ac2, wait for changes on ac1") + ac2.mark_seen_messages([msg_in]) + lp.step("1") + ac1._evlogger.get_matching("DC_EVENT_MSG_READ") + lp.step("2") + # ac1._evlogger.get_info_matching("Message marked as seen") + assert msg_out.get_state().is_out_mdn_received() + + def test_saved_mime_on_received_message(self, acfactory, lp): + lp.sec("starting accounts, waiting for configuration") + ac1 = acfactory.get_online_configuring_account() + ac2 = acfactory.get_online_configuring_account() + ac2.set_config("save_mime_headers", "1") + c2 = ac1.create_contact(email=ac2.get_config("addr")) + chat = ac1.create_chat_by_contact(c2) + wait_configuration_progress(ac1, 1000) + wait_configuration_progress(ac2, 1000) + lp.sec("sending text message from ac1 to ac2") + msg_out = chat.send_text("message1") + ac1._evlogger.get_matching("DC_EVENT_MSG_DELIVERED") + assert msg_out.get_mime_headers() is None + + lp.sec("wait for ac2 to receive message") + ev = ac2._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + in_id = ev[2] + mime = ac2.get_message_by_id(in_id).get_mime_headers() + assert mime.get_all("From") + assert mime.get_all("Received") + + def test_send_and_receive_image(self, acfactory, lp, data): + lp.sec("starting accounts, waiting for configuration") + ac1 = acfactory.get_online_configuring_account() + ac2 = acfactory.get_online_configuring_account() + c2 = ac1.create_contact(email=ac2.get_config("addr")) + chat = ac1.create_chat_by_contact(c2) + + wait_configuration_progress(ac1, 1000) + wait_configuration_progress(ac2, 1000) + + lp.sec("sending image message from ac1 to ac2") + path = data.get_path("d.png") + msg_out = chat.send_image(path) + ev = ac1._evlogger.get_matching("DC_EVENT_MSG_DELIVERED") + evt_name, data1, data2 = ev + assert data1 == chat.id + assert data2 == msg_out.id + assert msg_out.get_state().is_out_delivered() + + lp.sec("wait for ac2 to receive message") + ev = ac2._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + assert ev[2] == msg_out.id + msg_in = ac2.get_message_by_id(msg_out.id) + assert msg_in.view_type.is_image() + assert os.path.exists(msg_in.filename) + assert os.stat(msg_in.filename).st_size == os.stat(path).st_size diff --git a/python/tests/test_increation.py b/python/tests/test_increation.py new file mode 100644 index 000000000..095a9592b --- /dev/null +++ b/python/tests/test_increation.py @@ -0,0 +1,69 @@ +from __future__ import print_function +import os +import shutil +from filecmp import cmp +from deltachat import const +from conftest import wait_configuration_progress, wait_msgs_changed + + +class TestInCreation: + def test_forward_increation(self, acfactory, data, lp): + ac1 = acfactory.get_online_configuring_account() + ac2 = acfactory.get_online_configuring_account() + wait_configuration_progress(ac1, 1000) + wait_configuration_progress(ac2, 1000) + + blobdir = ac1.get_blobdir() + + c2 = ac1.create_contact(email=ac2.get_config("addr")) + chat = ac1.create_chat_by_contact(c2) + assert chat.id >= const.DC_CHAT_ID_LAST_SPECIAL + wait_msgs_changed(ac1, 0, 0) # why no chat id? + + lp.sec("create a message with a file in creation") + path = os.path.join(blobdir, "d.png") + open(path, 'a').close() + prepared_original = chat.prepare_file(path) + assert prepared_original.get_state().is_out_preparing() + wait_msgs_changed(ac1, chat.id, prepared_original.id) + + lp.sec("forward the message while still in creation") + chat2 = ac1.create_group_chat("newgroup") + chat2.add_contact(c2) + wait_msgs_changed(ac1, 0, 0) # why not chat id? + ac1.forward_messages([prepared_original], chat2) + forwarded_id = wait_msgs_changed(ac1, chat2.id) + forwarded_msg = ac1.get_message_by_id(forwarded_id) + assert forwarded_msg.get_state().is_out_preparing() + + lp.sec("finish creating the file and send it") + shutil.copy(data.get_path("d.png"), path) + sent_original = chat.send_prepared(prepared_original) + assert sent_original.id == prepared_original.id + state = sent_original.get_state() + assert state.is_out_pending() or state.is_out_delivered() + wait_msgs_changed(ac1, chat.id, sent_original.id) + + lp.sec("expect the forwarded message to be sent now too") + wait_msgs_changed(ac1, chat2.id, forwarded_id) + state = ac1.get_message_by_id(forwarded_id).get_state() + assert state.is_out_pending() or state.is_out_delivered() + + lp.sec("wait for the messages to be delivered to SMTP") + ev = ac1._evlogger.get_matching("DC_EVENT_MSG_DELIVERED") + assert ev[1] == chat.id + assert ev[2] == sent_original.id + ev = ac1._evlogger.get_matching("DC_EVENT_MSG_DELIVERED") + assert ev[1] == chat2.id + assert ev[2] == forwarded_id + + lp.sec("wait for both messages to arrive") + ev1 = ac2._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + assert ev1[1] >= const.DC_CHAT_ID_LAST_SPECIAL + received_original = ac2.get_message_by_id(ev1[2]) + assert cmp(received_original.filename, path, False) + ev2 = ac2._evlogger.get_matching("DC_EVENT_MSGS_CHANGED") + assert ev2[1] >= const.DC_CHAT_ID_LAST_SPECIAL + assert ev2[1] != ev1[1] + received_copy = ac2.get_message_by_id(ev2[2]) + assert cmp(received_copy.filename, path, False) diff --git a/python/tests/test_lowlevel.py b/python/tests/test_lowlevel.py new file mode 100644 index 000000000..e3c5705b8 --- /dev/null +++ b/python/tests/test_lowlevel.py @@ -0,0 +1,29 @@ +from __future__ import print_function +import pytest +from deltachat import capi, Account, const + + +def test_empty_context(): + ctx = capi.lib.dc_context_new(capi.ffi.NULL, capi.ffi.NULL, capi.ffi.NULL) + capi.lib.dc_close(ctx) + + +def test_wrong_db(tmpdir): + tmpdir.join("hello.db").write("123") + with pytest.raises(ValueError): + Account(db_path=tmpdir.strpath) + + +def test_event_defines(): + assert const.DC_EVENT_INFO == 100 + assert const.DC_CONTACT_ID_SELF + + +def test_sig(): + sig = capi.lib.dc_get_event_signature_types + assert sig(const.DC_EVENT_INFO) == 2 + assert sig(const.DC_EVENT_WARNING) == 2 + assert sig(const.DC_EVENT_ERROR) == 2 + assert sig(const.DC_EVENT_SMTP_CONNECTED) == 2 + assert sig(const.DC_EVENT_IMAP_CONNECTED) == 2 + assert sig(const.DC_EVENT_SMTP_MESSAGE_SENT) == 2 diff --git a/python/tox.ini b/python/tox.ini new file mode 100644 index 000000000..8f710dd61 --- /dev/null +++ b/python/tox.ini @@ -0,0 +1,60 @@ +[tox] +# make sure to update environment list in travis.yml and appveyor.yml +envlist = + py27 + py35 + lint + auditwheels + +[testenv] +commands = + pytest -rsXx {posargs:tests} + pip wheel . -w {toxworkdir}/wheelhouse + +passenv = + TRAVIS + LD_LIBRARY_PATH + CFLAGS +deps = + pytest + pytest-faulthandler + pdbpp + +[testenv:auditwheels] +skipsdist = True +commands = + python tests/auditwheels.py {toxworkdir}/wheelhouse + + +[testenv:lint] +skipsdist = True +usedevelop = True +basepython = python2.7 +deps = + flake8 + # pygments required by rst-lint + pygments + restructuredtext_lint +commands = + flake8 src/deltachat + flake8 tests/ + rst-lint --encoding 'utf-8' README.rst + +[testenv:doc] +basepython = python3.5 +deps = + sphinx==2.0.1 + breathe + +changedir = doc +commands = + sphinx-build -w docker-toxdoc-warnings.log -b html . _build/html + + +[pytest] +python_files = tests/test_*.py +norecursedirs = .tox +xfail_strict=true + +[flake8] +max-line-length = 120