Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f13094bbb | |||
| b85425df6f | |||
| 3b2b25226d | |||
| bf617e12b0 | |||
| e8f51cb41a | |||
| 573d4f0198 | |||
| f100a18f22 | |||
| bf2d8a1e99 | |||
| f66ef8ee5c | |||
| 06098265d1 | |||
| cbbc8a3b87 | |||
| 0fb0ba0c74 | |||
| 0985f1b886 | |||
| ec99c62348 | |||
| 71fc565a7e |
58 changed files with 149 additions and 1840 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -2,18 +2,14 @@
|
||||||
*.pyo
|
*.pyo
|
||||||
*.egg-info
|
*.egg-info
|
||||||
*.project
|
*.project
|
||||||
*.log
|
|
||||||
*.swp
|
*.swp
|
||||||
*.pydevproject
|
*.pydevproject
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
*.ropeproject
|
*.ropeproject
|
||||||
uv.lock
|
|
||||||
.env
|
.env
|
||||||
.private*
|
|
||||||
.pytest.ini
|
.pytest.ini
|
||||||
*#*#
|
*#*#
|
||||||
*.#*
|
*.#*
|
||||||
__pycache__
|
__pycache__
|
||||||
log
|
|
||||||
var
|
var
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# py-scopes: apidemo
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# py-scopes/apidemo/config.py
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
|
|
||||||
#, env_prefix='SCOPES_')
|
|
||||||
|
|
||||||
base_url: str = 'https://test.cy7.eu'
|
|
||||||
|
|
||||||
log_file: str = 'log/scopes.log'
|
|
||||||
log_level: str = 'INFO'
|
|
||||||
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
|
|
||||||
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
|
|
||||||
|
|
||||||
dbengine: str = 'postgresql+psycopg'
|
|
||||||
dbname: str = 'demo'
|
|
||||||
dbuser: str = 'demo'
|
|
||||||
dbpassword: str = 'secret'
|
|
||||||
dbschema: str = 'demo'
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import logging
|
|
||||||
from os import getenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
base_url = settings.base_url
|
|
||||||
|
|
||||||
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
|
|
||||||
format=settings.log_format, datefmt=settings.log_dateformat)
|
|
||||||
logging.getLogger('uvicorn.error').propagate = True
|
|
||||||
|
|
||||||
# storage settings
|
|
||||||
from scopes.storage.db.postgres import StorageFactory
|
|
||||||
dbengine = settings.dbengine
|
|
||||||
dbname = settings.dbname
|
|
||||||
dbuser = settings.dbuser
|
|
||||||
dbpassword = settings.dbpassword
|
|
||||||
dbschema = settings.dbschema
|
|
||||||
|
|
||||||
# authentication settings
|
|
||||||
oidc_provider = getenv('OIDC_PROVIDER', 'https://a1.cy7.de')
|
|
||||||
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
|
|
||||||
oidc_params = dict(
|
|
||||||
op_config_url=oidc_provider + '/.well-known/openid-configuration',
|
|
||||||
op_uris=None,
|
|
||||||
op_keys=None,
|
|
||||||
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
|
|
||||||
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
|
|
||||||
client_id = oidc_client_id,
|
|
||||||
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
|
|
||||||
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
|
|
||||||
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
|
|
||||||
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
|
|
||||||
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE', '.private-key.json'),
|
|
||||||
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
|
|
||||||
project_id=getenv('OIDC_PROJECT_ID', None),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# py-scopes: demo
|
|
||||||
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# py-scopes/demo/config.py
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import logging
|
|
||||||
from os import getenv
|
|
||||||
from scopes.web.app import zope_app_factory
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
log_file = 'log/scopes.log'
|
|
||||||
log_level = logging.DEBUG
|
|
||||||
log_format = '%(asctime)s %(levelname)s %(name)s %(message)s'
|
|
||||||
log_dateformat = '%Y-%m-%dT%H:%M:%S'
|
|
||||||
logging.basicConfig(filename=log_file, level=log_level,
|
|
||||||
format=log_format, datefmt=log_dateformat)
|
|
||||||
|
|
||||||
server_port = getenv('SERVER_PORT', '8099')
|
|
||||||
base_url = getenv('BASE_URL', 'https://demo.cy7.de')
|
|
||||||
|
|
||||||
app_factory = zope_app_factory
|
|
||||||
|
|
||||||
# storage settings
|
|
||||||
from scopes.storage.db.postgres import StorageFactory
|
|
||||||
dbengine = 'postgresql+psycopg'
|
|
||||||
dbname = getenv('DBNAME', 'demo')
|
|
||||||
dbuser = getenv('DBUSER', 'demo')
|
|
||||||
dbpassword = getenv('DBPASSWORD', 'secret')
|
|
||||||
dbschema = getenv('DBSCHEMA', 'demo')
|
|
||||||
|
|
||||||
# authentication settings
|
|
||||||
oidc_provider = getenv('OIDC_PROVIDER', 'https://a1.cy7.de')
|
|
||||||
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
|
|
||||||
oidc_params = dict(
|
|
||||||
op_config_url=oidc_provider + '/.well-known/openid-configuration',
|
|
||||||
op_uris=None,
|
|
||||||
op_keys=None,
|
|
||||||
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
|
|
||||||
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
|
|
||||||
client_id = oidc_client_id,
|
|
||||||
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
|
|
||||||
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
|
|
||||||
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
|
|
||||||
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
|
|
||||||
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE', '.private-key.json'),
|
|
||||||
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
|
|
||||||
project_id=getenv('OIDC_PROJECT_ID', None),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# py-scopes/demo/demo_server.py
|
|
||||||
|
|
||||||
from scopes.web.auth import oidc
|
|
||||||
from scopes.storage import topic
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import waitress
|
|
||||||
|
|
||||||
|
|
||||||
def run(app, config):
|
|
||||||
oidc.startup() # todo: use generic app.startServices()
|
|
||||||
port = int(config.server_port)
|
|
||||||
print(f'Serving on port {port}.')
|
|
||||||
waitress.serve(app, port=port)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
import config
|
|
||||||
app = config.app_factory(config)
|
|
||||||
run(app, config)
|
|
||||||
# see zope.app.wsgi.getWSGIApplication
|
|
||||||
17
demo/env.in
17
demo/env.in
|
|
@ -1,17 +0,0 @@
|
||||||
# s10: py-scopes/demo/.env
|
|
||||||
# input (example) file - copy to .env and edit.
|
|
||||||
|
|
||||||
SERVER_PORT=8800
|
|
||||||
BASE_URL=https://demo.cy7.de
|
|
||||||
|
|
||||||
DBNAME=demo
|
|
||||||
DBUSER=demo
|
|
||||||
DBPASSWORD=secret
|
|
||||||
DBSCHEMA=demo
|
|
||||||
|
|
||||||
OIDC_PROVIDER=
|
|
||||||
OIDC_CLIENT_ID=
|
|
||||||
OIDC_COOKIE_CRYPT=
|
|
||||||
OIDC_ORGANIZATION_ID=
|
|
||||||
OIDC_PROJECT_ID=
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
directory for logfiles created by application
|
|
||||||
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# scopes/demo/shell.py
|
|
||||||
# simple shell for interactive testing / accessing the database (storage)
|
|
||||||
# use: `python -i shell.py`
|
|
||||||
|
|
||||||
import config
|
|
||||||
from scopes.web.auth import oidc
|
|
||||||
from scopes.storage.folder import Root
|
|
||||||
from scopes.storage import topic
|
|
||||||
|
|
||||||
storage = config.StorageFactory(config)(config.dbschema)
|
|
||||||
root = Root(storage)
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "py-scopes"
|
|
||||||
version = "3.0.1"
|
|
||||||
description = "Implementation of the unknown 'scopes' paradigm in Python"
|
|
||||||
readme = "README.md"
|
|
||||||
license = {text = "MIT"}
|
|
||||||
keywords = ["scopes"]
|
|
||||||
authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}]
|
|
||||||
|
|
||||||
dependencies = ["SQLAlchemy", "pydantic", "pydantic-settings"]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
postgres = [
|
|
||||||
"psycopg[binary]",
|
|
||||||
"transaction",
|
|
||||||
"zope.sqlalchemy",
|
|
||||||
]
|
|
||||||
app = [ # TODO: rename to zope
|
|
||||||
"python-dotenv",
|
|
||||||
"waitress",
|
|
||||||
"zope.authentication",
|
|
||||||
"zope.interface",
|
|
||||||
"zope.publisher",
|
|
||||||
"zope.traversing",
|
|
||||||
]
|
|
||||||
api = ["fastapi[standard]"]
|
|
||||||
auth = ["pyjwt[crypto]", "cryptography", "requests"]
|
|
||||||
test = ["zope.testrunner"] # TODO: rename to zopetest, or merge with apitest
|
|
||||||
apitest = ["pytest", "httpx2"]
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
packages = ["scopes"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
addopts = ["-s"]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
#python_files = ["test_api.py"]
|
|
||||||
# use .pytest.ini file for local test config
|
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# runtests.sh
|
|
||||||
# run all unit / doc tests
|
|
||||||
|
|
||||||
#zope-testrunner --test-path=. -m !test_api $*
|
|
||||||
zope-testrunner --test-path=. $*
|
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""package scopes.api"""
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# scopes.api.main
|
|
||||||
|
|
||||||
import sys
|
|
||||||
if '' not in sys.path:
|
|
||||||
sys.path = [''] + sys.path
|
|
||||||
|
|
||||||
import config
|
|
||||||
|
|
||||||
from scopes.storage import tracking
|
|
||||||
from scopes.storage.common import Storage
|
|
||||||
import transaction
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
|
|
||||||
def storage():
|
|
||||||
stf = config.StorageFactory(config)
|
|
||||||
return stf(config.dbschema)
|
|
||||||
|
|
||||||
# app and routes
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.get('/')
|
|
||||||
async def read_root():
|
|
||||||
return {'Hello': 'World'}
|
|
||||||
|
|
||||||
@app.get('/demo')
|
|
||||||
async def read_demo():
|
|
||||||
return HTMLResponse('<b>Hello World</b>')
|
|
||||||
|
|
||||||
@app.get('/persons/{name}')
|
|
||||||
async def read_person(name: str, q: str | None = None):
|
|
||||||
return {'name': name, 'query': q}
|
|
||||||
|
|
||||||
@app.get('/tracks')
|
|
||||||
async def get_tracks(storage: Annotated[Storage, Depends(storage)]):
|
|
||||||
tracks = storage.create(tracking.Container)
|
|
||||||
result = [tr.asDict() for tr in tracks.query()]
|
|
||||||
transaction.commit()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# scopes.api.message
|
|
||||||
|
|
||||||
"""routes and views for Message objects"""
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
"""package scopes.core"""
|
|
||||||
|
|
||||||
from .core import *
|
|
||||||
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
# scopes.core.actor
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from scopes.util import keywords as k, Symbol
|
|
||||||
|
|
||||||
# keywords:
|
|
||||||
k('active full ok quit retired suspended')
|
|
||||||
|
|
||||||
type Behavior = Callable[[Any], Awaitable[Behavior | None]]
|
|
||||||
|
|
||||||
class Mailbox:
|
|
||||||
|
|
||||||
def __init__(self, maxsize:int = 0, status: Symbol = k.active):
|
|
||||||
self.queue: asyncio.Queue = asyncio.Queue(maxsize)
|
|
||||||
self.status: Symbol = status
|
|
||||||
|
|
||||||
async def loop(q: asyncio.Queue, bhv: Behavior) -> None:
|
|
||||||
while True:
|
|
||||||
msg = await q.get()
|
|
||||||
if msg is k.quit:
|
|
||||||
break
|
|
||||||
bhv = await bhv(msg) or bhv
|
|
||||||
q.task_done()
|
|
||||||
|
|
||||||
def create(bhv: Behavior, maxsize: int = 0) -> Mailbox:
|
|
||||||
mb = Mailbox(maxsize)
|
|
||||||
asyncio.create_task(loop(mb.queue, bhv))
|
|
||||||
return mb
|
|
||||||
|
|
||||||
async def send(mb: Mailbox, msg: Any, no_wait: bool = False) -> Symbol:
|
|
||||||
if mb.status is k.retired:
|
|
||||||
return k.retired
|
|
||||||
if no_wait and mb.queue.full():
|
|
||||||
return k.full
|
|
||||||
await mb.queue.put(msg)
|
|
||||||
return k.ok
|
|
||||||
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
# scopes.core.core
|
|
||||||
|
|
||||||
"""A concurrent cybernetic communication system"""
|
|
||||||
|
|
||||||
from __future__ import annotations # no check of type annotations at runtime
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Self
|
|
||||||
|
|
||||||
from scopes.core import actor
|
|
||||||
from scopes.core.message import Message
|
|
||||||
from scopes import util
|
|
||||||
from scopes.util import keywords as k, Symbol, Symbols
|
|
||||||
|
|
||||||
# keywords (domains, actions, classes, stages, data keys):
|
|
||||||
k('csys env _meta',
|
|
||||||
'connect create created data default query set value',
|
|
||||||
'c00 e00 s00 op rcvr',
|
|
||||||
'initial active retired',
|
|
||||||
'address pred succ')
|
|
||||||
|
|
||||||
type Categ = tuple[Symbol, Symbol]
|
|
||||||
type Proc = Callable[[Message, Scope], Awaitable[Scope | None]]
|
|
||||||
type Program = Callable[[Scope], Proc]
|
|
||||||
type ProgramSpec = dict[tuple[Symbol, Symbol], Proc]
|
|
||||||
type Syn = Callable[[Message], Awaitable[Symbol | None]]
|
|
||||||
type SynOp = Callable[[Message], Message | None]
|
|
||||||
|
|
||||||
type Handler = Callable[[Message, Scope],
|
|
||||||
Awaitable[Message | None | tuple[Message | None, Scope | None]]]
|
|
||||||
type Handlers = Handler | None | list[Handlers]
|
|
||||||
type Actions = dict[Symbol, Handlers]
|
|
||||||
|
|
||||||
def neuron(scope: Scope) -> actor.Mailbox:
|
|
||||||
async def process(msg: Message) -> actor.Behavior | None:
|
|
||||||
nonlocal scope
|
|
||||||
scope = await scope.proc(msg, scope) or scope
|
|
||||||
return process
|
|
||||||
return actor.create(process)
|
|
||||||
|
|
||||||
def synapse(rcvr: actor.Mailbox, op: SynOp = lambda x: x) -> Syn:
|
|
||||||
async def syn(msg: Message) -> Symbol | None:
|
|
||||||
msg_new = op(msg)
|
|
||||||
if msg_new is not None:
|
|
||||||
return await actor.send(rcvr, msg_new)
|
|
||||||
return None
|
|
||||||
return syn
|
|
||||||
|
|
||||||
env_dummy: actor.Mailbox = actor.Mailbox()
|
|
||||||
|
|
||||||
def notify() -> Handler:
|
|
||||||
async def handle(msg: Message, scope: Scope) -> None:
|
|
||||||
await actor.send(scope.environ, msg)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def gen_proc(actions: Actions, default: Handlers = notify(),
|
|
||||||
prepare: Handlers = None, finalize: Handlers = None) -> Proc:
|
|
||||||
async def proc(msg: Message, scope: Scope) -> Scope:
|
|
||||||
acts = actions.get(msg.action, actions.get(k.default, default))
|
|
||||||
acts = util.flatten(prepare, acts, finalize, ignore_none=True)
|
|
||||||
for act in acts:
|
|
||||||
msg = await act(msg, scope)
|
|
||||||
if isinstance(msg, tuple):
|
|
||||||
msg, new_scope = msg
|
|
||||||
if new_scope is not None:
|
|
||||||
scope = new_scope
|
|
||||||
if msg is None:
|
|
||||||
break
|
|
||||||
return scope
|
|
||||||
return proc
|
|
||||||
|
|
||||||
def gen_program(spec: ProgramSpec, default: Proc = gen_proc({})) -> Program:
|
|
||||||
def program(scope: Scope) -> Proc:
|
|
||||||
key = (scope.categ[1], scope.stage)
|
|
||||||
proc = spec.get(key, spec.get((key[0], k.default)))
|
|
||||||
if proc is None:
|
|
||||||
proc = spec.get((k.default, key[1]), default)
|
|
||||||
return proc
|
|
||||||
return program
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Scope:
|
|
||||||
|
|
||||||
value: Any = 0
|
|
||||||
categ: Categ = (k.csys, k.c00)
|
|
||||||
stage: Symbol = k.initial
|
|
||||||
proc: Proc = field(init=False)
|
|
||||||
program: Program = gen_program({})
|
|
||||||
syns: list[Syn | None] = field(default_factory=list)
|
|
||||||
environ: actor.Mailbox = env_dummy
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
self.set_proc()
|
|
||||||
|
|
||||||
def set_proc(self) -> Self:
|
|
||||||
self.proc = self.program(self)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def create_scope(env: actor.Mailbox, cat: Categ = (k.csys, k.c00), **kw) -> Scope:
|
|
||||||
return Scope(environ=env, categ=cat, **kw)
|
|
||||||
|
|
||||||
def clone_scope(scope: Scope, **kw) -> Scope:
|
|
||||||
value = kw.get('value', 0)
|
|
||||||
categ = kw.get('categ', scope.categ)
|
|
||||||
stage = kw.get('stage', k.initial)
|
|
||||||
return Scope(environ=scope.environ, value=value, categ=categ, stage=stage,
|
|
||||||
program=scope.program, syns=[])
|
|
||||||
|
|
||||||
async def create_zero(env: actor.Mailbox) -> actor.Mailbox:
|
|
||||||
scope = create_scope(env, program=gen_program({}, default=std_proc()))
|
|
||||||
zero = neuron(scope)
|
|
||||||
await notify_created(Message(k.csys), zero, scope)
|
|
||||||
return zero
|
|
||||||
|
|
||||||
# predefined action handlers and Action sets
|
|
||||||
|
|
||||||
def noop(no_forward: bool = False) -> Handler:
|
|
||||||
async def handle(msg: Message, scope: Scope) -> Message | None:
|
|
||||||
return None if no_forward else msg
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def forward(no_syns_notify: bool = False) -> Handler:
|
|
||||||
do_notify = notify() if no_syns_notify else None
|
|
||||||
async def handle(msg: Message, scope: Scope) -> None:
|
|
||||||
count = 0
|
|
||||||
for idx, syn in enumerate(scope.syns):
|
|
||||||
res = await syn(msg) if syn is not None else None
|
|
||||||
match res:
|
|
||||||
case k.retired: scope.syns[idx] = None
|
|
||||||
case k.ok: count += 1
|
|
||||||
if count == 0 and do_notify:
|
|
||||||
do_notify(msg, scope)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def value_add() -> Handler:
|
|
||||||
async def handle(msg: Message, scope: Scope) -> tuple[Message, Scope]:
|
|
||||||
return msg, scope
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def create() -> Handler:
|
|
||||||
async def handle(msg: Message, scope: Scope) -> None:
|
|
||||||
new = neuron(clone_scope(scope, **msg.data))
|
|
||||||
await notify_created(msg, new, scope)
|
|
||||||
conn = msg.data.get(k.connect)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def connect() -> Handler:
|
|
||||||
async def handle(msg: Message, scope: Scope) -> None:
|
|
||||||
return None
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def basic_actions() -> Actions:
|
|
||||||
acts: Actions = {
|
|
||||||
k.value: value_add(),
|
|
||||||
k.create: create(),
|
|
||||||
k.connect: connect()
|
|
||||||
}
|
|
||||||
return acts
|
|
||||||
|
|
||||||
# predefined specialized procs
|
|
||||||
|
|
||||||
def std_proc(actions: Actions | None = None, finalize: Handler = forward(),
|
|
||||||
more: Actions = {}) -> Proc:
|
|
||||||
if actions is None:
|
|
||||||
actions = basic_actions()
|
|
||||||
actions.update(more)
|
|
||||||
return gen_proc(actions, finalize=finalize)
|
|
||||||
|
|
||||||
# helpers
|
|
||||||
|
|
||||||
async def notify_created(msg: Message, new: actor.Mailbox, scope: Scope) -> None:
|
|
||||||
msg = Message(msg.domain, k.created, data=msg.data | {k.new: new})
|
|
||||||
await notify()(msg, scope)
|
|
||||||
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# scopes.core.environ
|
|
||||||
|
|
||||||
"""Defining an environment for the communication system"""
|
|
||||||
|
|
||||||
from scopes import core
|
|
||||||
from scopes.core import actor
|
|
||||||
from scopes.core.message import Message
|
|
||||||
from scopes.util import keywords as k
|
|
||||||
|
|
||||||
def create_env(meta_env: actor.Mailbox) -> actor.Mailbox:
|
|
||||||
scope = core.create_scope(meta_env, (k.env, k.c00),
|
|
||||||
program=core.gen_program({}, default=env_proc()))
|
|
||||||
return core.neuron(scope)
|
|
||||||
|
|
||||||
def env_proc() -> core.Proc:
|
|
||||||
return core.gen_proc(env_actions(), default=None, finalize=core.notify())
|
|
||||||
|
|
||||||
def handle_created() -> core.Handler:
|
|
||||||
async def handle(msg: Message, scope: core.Scope) -> Message:
|
|
||||||
return msg
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def env_actions() -> core.Actions:
|
|
||||||
return {
|
|
||||||
k.created: handle_created(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# scopes.core.message
|
|
||||||
|
|
||||||
"""Generic message definition"""
|
|
||||||
|
|
||||||
from scopes import shape
|
|
||||||
|
|
||||||
class Message(shape.Record):
|
|
||||||
|
|
||||||
headFields = ('domain', 'action', 'class', 'item')
|
|
||||||
headFieldsAsKeywords = {'domain', 'action', 'class'}
|
|
||||||
|
|
||||||
customer = None
|
|
||||||
|
|
||||||
def addr(self):
|
|
||||||
hv = self.headValues
|
|
||||||
return hv[0], hv[2], hv[3]
|
|
||||||
|
|
||||||
def categ(self):
|
|
||||||
return self.domain, self.head['class']
|
|
||||||
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
# scopes.interfaces
|
# scopes.interfaces
|
||||||
|
|
||||||
from zope.interface import Interface, Attribute
|
from zope.interface import Interface
|
||||||
|
|
||||||
|
|
||||||
class IViewable(Interface):
|
class ITraversable(Interface):
|
||||||
|
|
||||||
prefix = Attribute('Prefix string for identifying the type (class) of an object')
|
|
||||||
|
|
||||||
|
|
||||||
class ITraversable(IViewable):
|
|
||||||
|
|
||||||
def get(key, default=None):
|
def get(key, default=None):
|
||||||
"""Return the item addressed by `key`; return `default` if not found."""
|
"""Return the item addressed by `key`; return `default` if not found."""
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""package scopes.org"""
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# scopes.org.mail
|
|
||||||
|
|
||||||
from zope.interface import implementer
|
|
||||||
from zope.sendmail.interfaces import IMailDelivery
|
|
||||||
|
|
||||||
"""Utilities for creating and sending emails."""
|
|
||||||
|
|
||||||
|
|
||||||
@implementer(IMailDelivery)
|
|
||||||
class DummyMailDelivery:
|
|
||||||
"""For testing purposes: just store mails in maildata.log"""
|
|
||||||
|
|
||||||
def send(self, fromaddr, toaddrs, message):
|
|
||||||
print("DummyMailDelivery")
|
|
||||||
print(f"fromaddr: {fromaddr}, toaddrs: {toaddrs}")
|
|
||||||
print(message)
|
|
||||||
1
scopes/organize/__init__.py
Normal file
1
scopes/organize/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""package scopes.organize"""
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# scopes.org.task
|
# scopes.organize.task
|
||||||
|
|
||||||
"""Task (and corresponding container) implementation."""
|
"""Task (and corresponding container) implementation."""
|
||||||
|
|
||||||
1
scopes/server/__init__.py
Normal file
1
scopes/server/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""package scopes.server"""
|
||||||
|
|
@ -1,31 +1,22 @@
|
||||||
# scopes.web.app
|
# scopes.server.app
|
||||||
|
|
||||||
import logging
|
|
||||||
from zope.i18n.interfaces import IUserPreferredCharsets
|
|
||||||
from zope.interface import implementer
|
|
||||||
from zope.publisher.base import DefaultPublication
|
from zope.publisher.base import DefaultPublication
|
||||||
from zope.publisher.browser import BrowserRequest
|
from zope.publisher.browser import BrowserRequest
|
||||||
from zope.publisher.interfaces import NotFound
|
from zope.publisher.interfaces import NotFound
|
||||||
from zope.publisher.publish import publish
|
from zope.publisher.publish import publish
|
||||||
|
|
||||||
from scopes.interfaces import ITraversable, IView
|
from scopes.interfaces import ITraversable, IView
|
||||||
from scopes.web.browser import getView
|
from scopes.server.browser import getView
|
||||||
import scopes.storage.concept # register container classes
|
import scopes.storage.concept # register container classes
|
||||||
from scopes.storage.folder import Root
|
from scopes.storage.folder import Root
|
||||||
|
|
||||||
|
|
||||||
@implementer(IUserPreferredCharsets)
|
|
||||||
class Request(BrowserRequest):
|
|
||||||
def getPreferredCharsets(self):
|
|
||||||
return ['UTF-8']
|
|
||||||
|
|
||||||
|
|
||||||
def zope_app_factory(config):
|
def zope_app_factory(config):
|
||||||
storageFactory = config.StorageFactory(config)
|
storageFactory = config.StorageFactory(config)
|
||||||
def zope_app(environ, start_response):
|
def zope_app(environ, start_response):
|
||||||
storage = storageFactory(config.dbschema)
|
storage = storageFactory(config.dbschema)
|
||||||
appRoot = Root(storage)
|
appRoot = Root(storage)
|
||||||
request = Request(environ['wsgi.input'], environ)
|
request = BrowserRequest(environ['wsgi.input'], environ)
|
||||||
request.setPublication(Publication(appRoot))
|
request.setPublication(Publication(appRoot))
|
||||||
request = publish(request, True)
|
request = publish(request, True)
|
||||||
response = request.response
|
response = request.response
|
||||||
|
|
@ -36,12 +27,6 @@ def zope_app_factory(config):
|
||||||
|
|
||||||
class Publication(DefaultPublication):
|
class Publication(DefaultPublication):
|
||||||
|
|
||||||
def beforeTraversal(self, request):
|
|
||||||
super(Publication, self).beforeTraversal(request)
|
|
||||||
from scopes.web.auth.oidc import authentication
|
|
||||||
prc = authentication.authenticate(request)
|
|
||||||
request.setPrincipal(prc)
|
|
||||||
|
|
||||||
def traverseName(self, request, ob, name):
|
def traverseName(self, request, ob, name):
|
||||||
next = getView(request, ob, name)
|
next = getView(request, ob, name)
|
||||||
if next is not None:
|
if next is not None:
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
# scopes.web.browser
|
# scopes.server.browser
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
from scopes.interfaces import IContainer, IReference, IView
|
from scopes.interfaces import IContainer, IReference, IView
|
||||||
|
|
||||||
logger = logging.getLogger('web.browser')
|
|
||||||
|
|
||||||
views = {} # registry for all views: {name: {prefix: viewClass, ...}, ...}
|
views = {} # registry for all views: {name: {prefix: viewClass, ...}, ...}
|
||||||
|
|
||||||
def register(name, *contextTypes):
|
def register(name, *contextClasses):
|
||||||
"""Use as decorator: `@register(name, class_or_prefix, ...).
|
"""Use as decorator: `@register(name, class, ...).
|
||||||
No class (or `None` or `''`) means default view for all classes."""
|
class `None` means default view for all classes."""
|
||||||
def doRegister(factory):
|
def doRegister(factory):
|
||||||
implementer(IView)(factory)
|
implementer(IView)(factory)
|
||||||
nameEntry = views.setdefault(name, {})
|
nameEntry = views.setdefault(name, {})
|
||||||
cts = contextTypes or ['']
|
for cl in contextClasses:
|
||||||
for ct in cts:
|
nameEntry[cl.prefix] = factory
|
||||||
if not isinstance(ct, str):
|
else:
|
||||||
ct = ct.prefix
|
nameEntry[''] = factory
|
||||||
nameEntry[ct] = factory
|
|
||||||
return factory
|
return factory
|
||||||
return doRegister
|
return doRegister
|
||||||
|
|
||||||
|
|
@ -32,7 +28,6 @@ def getView(request, ob, name):
|
||||||
factory = nameEntry.get('')
|
factory = nameEntry.get('')
|
||||||
if factory is None:
|
if factory is None:
|
||||||
return None
|
return None
|
||||||
logger.debug('getView: %s %s', ob, request['PATH_INFO'])
|
|
||||||
return factory(ob, request)
|
return factory(ob, request)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -59,11 +54,10 @@ class DefaultView:
|
||||||
result['target'] = target.asDict()
|
result['target'] = target.asDict()
|
||||||
if IContainer.providedBy(target):
|
if IContainer.providedBy(target):
|
||||||
result['target']['items'] = [v.asDict() for v in target.values()]
|
result['target']['items'] = [v.asDict() for v in target.values()]
|
||||||
prc = self.request.principal
|
|
||||||
if prc is not None:
|
|
||||||
result['principal'] = prc.asDict()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def render(self, result):
|
def render(self, result):
|
||||||
self.request.response.setHeader('Content-type', 'application/json; charset=utf-8')
|
self.request.response.setHeader('Content-type', 'application/json; charset=utf-8')
|
||||||
return json.dumps(result).encode('UTF-8')
|
return json.dumps(result).encode('UTF-8')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# scopes.shape
|
|
||||||
|
|
||||||
"""really abstract basic data shape definitions"""
|
|
||||||
|
|
||||||
from scopes.util import keywords as k
|
|
||||||
|
|
||||||
class Record:
|
|
||||||
|
|
||||||
headFields: tuple = () # to be defined by subclass
|
|
||||||
headFieldsAsKeywords: set[str] = set()
|
|
||||||
defaultHeadValue = ''
|
|
||||||
|
|
||||||
def __init__(self, *headValues, data=None, **kw):
|
|
||||||
self.head = dict((k, self.defaultHeadValue) for k in self.headFields)
|
|
||||||
for k, v in kw.items():
|
|
||||||
self.set(k, v)
|
|
||||||
for ix, hv in enumerate(headValues):
|
|
||||||
self.set(self.headFields[ix], hv)
|
|
||||||
self.data = data or {}
|
|
||||||
|
|
||||||
def __getattr__(self, k):
|
|
||||||
raise AttributeError(k)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def headValues(self):
|
|
||||||
return [self.head[k] for k in self.headFields]
|
|
||||||
|
|
||||||
def set(self, attr, value):
|
|
||||||
if attr in self.headFields:
|
|
||||||
if value is None:
|
|
||||||
value = self.defaultHeadValue
|
|
||||||
elif attr in self.headFieldsAsKeywords:
|
|
||||||
value = k[value]
|
|
||||||
self.head[attr] = value
|
|
||||||
setattr(self, attr, value)
|
|
||||||
else:
|
|
||||||
raise AttributeError(attr)
|
|
||||||
|
|
||||||
def update(self, data, overwrite=False):
|
|
||||||
if data is None:
|
|
||||||
return
|
|
||||||
if overwrite:
|
|
||||||
self.data = data
|
|
||||||
else:
|
|
||||||
self.data.update(data)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<{self.__class__.__name__}: {self.headValues}, {self.data}>'
|
|
||||||
|
|
||||||
def asDict(self):
|
|
||||||
return dict(head=self.head, data=self.data)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,8 +52,8 @@ class Storage(object):
|
||||||
return metadata.tables.get((schema and schema + '.' or '') + tableName)
|
return metadata.tables.get((schema and schema + '.' or '') + tableName)
|
||||||
|
|
||||||
def dropTable(self, tableName):
|
def dropTable(self, tableName):
|
||||||
prefix = self.schema and self.schema + '.' or ''
|
|
||||||
with self.engine.begin() as conn:
|
with self.engine.begin() as conn:
|
||||||
|
prefix = self.schema and self.schema + '.' or ''
|
||||||
conn.execute(text('drop table if exists %s%s' % (prefix, tableName)))
|
conn.execute(text('drop table if exists %s%s' % (prefix, tableName)))
|
||||||
|
|
||||||
def resetSequence(self, tableName, colName, v):
|
def resetSequence(self, tableName, colName, v):
|
||||||
|
|
@ -63,19 +63,15 @@ class Storage(object):
|
||||||
conn.execute(text(sq))
|
conn.execute(text(sq))
|
||||||
|
|
||||||
|
|
||||||
class StorageFactory:
|
class StorageFactory(object):
|
||||||
|
|
||||||
def sessionFactory(self):
|
def sessionFactory(self):
|
||||||
return self.engine.connect
|
return self.engine.connect
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def getEngine(dbtype, dbname, user, pw, **kw):
|
def getEngine(dbtype, dbname, user, pw, host='localhost', port=5432, **kw):
|
||||||
return create_engine('%s:///%s' % (dbtype, dbname), **kw)
|
return create_engine('%s:///%s' % (dbtype, dbname), **kw)
|
||||||
|
|
||||||
def engineFromConfig(self, config):
|
|
||||||
return self.getEngine(config.dbengine, config.dbname,
|
|
||||||
config.dbuser, config.dbpassword)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mark_changed(session):
|
def mark_changed(session):
|
||||||
pass
|
pass
|
||||||
|
|
@ -90,7 +86,8 @@ class StorageFactory:
|
||||||
storageClass = Storage
|
storageClass = Storage
|
||||||
|
|
||||||
def __init__(self, config, storageClass=None):
|
def __init__(self, config, storageClass=None):
|
||||||
self.engine = self.engineFromConfig(config)
|
self.engine = self.getEngine(config.dbengine, config.dbname,
|
||||||
|
config.dbuser, config.dbpassword)
|
||||||
self.Session = self.sessionFactory()
|
self.Session = self.sessionFactory()
|
||||||
if storageClass is not None:
|
if storageClass is not None:
|
||||||
self.storageClass = storageClass
|
self.storageClass = storageClass
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,6 @@ class StorageFactory(StorageFactory):
|
||||||
return create_engine('%s://%s:%s@%s:%s/%s' % (
|
return create_engine('%s://%s:%s@%s:%s/%s' % (
|
||||||
dbtype, user, pw, host, port, dbname), **kw)
|
dbtype, user, pw, host, port, dbname), **kw)
|
||||||
|
|
||||||
def engineFromConfig(self, config):
|
|
||||||
return self.getEngine(config.dbengine, config.dbname,
|
|
||||||
config.dbuser, config.dbpassword,
|
|
||||||
host=getattr(config, 'dbhost', 'localhost'),
|
|
||||||
port=getattr(config, 'dbport', 5432))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mark_changed(session):
|
def mark_changed(session):
|
||||||
return mark_changed(session)
|
return mark_changed(session)
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,6 @@ from scopes.storage.common import registerContainerClass
|
||||||
from scopes.storage.tracking import Container, Track
|
from scopes.storage.tracking import Container, Track
|
||||||
|
|
||||||
|
|
||||||
class DummyFolder(dict):
|
|
||||||
|
|
||||||
prefix = 'dummy'
|
|
||||||
|
|
||||||
def asDict(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return '<%s: %s>' % (self.__class__.__name__,
|
|
||||||
super(DummyFolder, self).__repr__())
|
|
||||||
|
|
||||||
|
|
||||||
@implementer(IContainer, IReference)
|
@implementer(IContainer, IReference)
|
||||||
class Folder(Track):
|
class Folder(Track):
|
||||||
|
|
||||||
|
|
@ -69,8 +57,6 @@ class Root(Folder):
|
||||||
"""A dummy (virtual) root folder for creating real folders
|
"""A dummy (virtual) root folder for creating real folders
|
||||||
using the Folder API."""
|
using the Folder API."""
|
||||||
|
|
||||||
prefix = 'root'
|
|
||||||
|
|
||||||
def __init__(self, storage):
|
def __init__(self, storage):
|
||||||
cont = storage.create(Folders)
|
cont = storage.create(Folders)
|
||||||
super(Root, self).__init__(container=cont)
|
super(Root, self).__init__(container=cont)
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# scopes.storage.message
|
|
||||||
|
|
||||||
"""Generic messages (or events) to be stored in SQL database."""
|
|
||||||
|
|
||||||
from scopes.core.message import Message as BaseMessage
|
|
||||||
from scopes.storage.common import registerContainerClass
|
|
||||||
#from scopes.storage.tracking import Container, Track
|
|
||||||
from scopes.storage.tracking import Container, Record
|
|
||||||
|
|
||||||
|
|
||||||
class Message(Record, BaseMessage):
|
|
||||||
|
|
||||||
prefix = 'msg'
|
|
||||||
|
|
||||||
|
|
||||||
@registerContainerClass
|
|
||||||
class Messages(Container):
|
|
||||||
|
|
||||||
itemFactory = Message
|
|
||||||
indexes = [('domain', 'action', 'class', 'item'), ('domain', 'class', 'item')]
|
|
||||||
tableName = 'messages'
|
|
||||||
insertOnChange = True
|
|
||||||
|
|
@ -12,22 +12,46 @@ from sqlalchemy import Table, Column, Index
|
||||||
from sqlalchemy import DateTime, Text, func
|
from sqlalchemy import DateTime, Text, func
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from scopes import shape
|
|
||||||
from scopes.storage.common import registerContainerClass
|
from scopes.storage.common import registerContainerClass
|
||||||
|
|
||||||
|
|
||||||
class Record(shape.Record):
|
class Track(object):
|
||||||
|
|
||||||
def __init__(self, *headValues, data=None, timeStamp=None, trackId=None,
|
headFields = ['taskId', 'userName']
|
||||||
container=None, **kw):
|
prefix = 'rec'
|
||||||
super().__init__(*headValues, data=data, **kw)
|
|
||||||
self.timeStamp = timeStamp
|
|
||||||
self.trackId = trackId
|
|
||||||
self.container = container
|
|
||||||
|
|
||||||
def asDict(self):
|
def __init__(self, *keys, **kw):
|
||||||
return dict(uid=self.uid, head=self.head, data=self.data,
|
self.head = {}
|
||||||
timeStamp=str(self.timeStamp)[:19])
|
for k, v in kw.items():
|
||||||
|
if k in self.headFields:
|
||||||
|
self.head[k] = kw.pop(k)
|
||||||
|
for ix, k in enumerate(keys):
|
||||||
|
self.head[self.headFields[ix]] = k
|
||||||
|
for k in self.headFields:
|
||||||
|
if self.head.get(k) is None:
|
||||||
|
self.head[k] = ''
|
||||||
|
setattr(self, k, self.head[k])
|
||||||
|
self.data = kw.get('data') or {}
|
||||||
|
self.timeStamp = kw.get('timeStamp')
|
||||||
|
self.trackId = kw.get('trackId')
|
||||||
|
self.container = kw.get('container')
|
||||||
|
|
||||||
|
def set(self, attr, value):
|
||||||
|
if attr in self.headFields:
|
||||||
|
if value is None:
|
||||||
|
value = ''
|
||||||
|
self.head[attr] = value
|
||||||
|
setattr(self, attr, value)
|
||||||
|
else:
|
||||||
|
raise AttributeError(attr)
|
||||||
|
|
||||||
|
def update(self, data, overwrite=False):
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
if overwrite:
|
||||||
|
self.data = data
|
||||||
|
else:
|
||||||
|
self.data.update(data)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uid(self):
|
def uid(self):
|
||||||
|
|
@ -41,6 +65,13 @@ class Record(shape.Record):
|
||||||
return ''
|
return ''
|
||||||
return str(self.trackId)
|
return str(self.trackId)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '%s: %s' % (self.__class__.__name__, self.asDict())
|
||||||
|
|
||||||
|
def asDict(self):
|
||||||
|
return dict(uid=self.uid, head=self.head, data=self.data,
|
||||||
|
timeStamp=str(self.timeStamp)[:19])
|
||||||
|
|
||||||
# shortcuts for compatibility with existing software:
|
# shortcuts for compatibility with existing software:
|
||||||
|
|
||||||
def updateIndex(self, **kw):
|
def updateIndex(self, **kw):
|
||||||
|
|
@ -51,14 +82,9 @@ class Record(shape.Record):
|
||||||
def storeTrack(self):
|
def storeTrack(self):
|
||||||
self.container.update(self)
|
self.container.update(self)
|
||||||
|
|
||||||
class Track(Record):
|
|
||||||
|
|
||||||
headFields = ['taskId', 'userName']
|
|
||||||
prefix = 'rec'
|
|
||||||
|
|
||||||
|
|
||||||
@registerContainerClass
|
@registerContainerClass
|
||||||
class Container:
|
class Container(object):
|
||||||
|
|
||||||
itemFactory = Track
|
itemFactory = Track
|
||||||
tableName = 'tracks'
|
tableName = 'tracks'
|
||||||
|
|
@ -90,8 +116,7 @@ class Container:
|
||||||
and_(*self.setupWhere(crit))).order_by(self.table.c.trackid)
|
and_(*self.setupWhere(crit))).order_by(self.table.c.trackid)
|
||||||
else:
|
else:
|
||||||
stmt = self.table.select().order_by(self.table.c.trackid)
|
stmt = self.table.select().order_by(self.table.c.trackid)
|
||||||
#return [self.makeTrack(r) for r in self.session.execute(stmt)]
|
for r in self.session.execute(stmt):
|
||||||
for r in self.session.execute(stmt).all():
|
|
||||||
yield self.makeTrack(r)
|
yield self.makeTrack(r)
|
||||||
|
|
||||||
def queryLast(self, **crit):
|
def queryLast(self, **crit):
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# scopes.testing
|
|
||||||
|
|
||||||
"""Common stuff (config, fixtures, ...) for testing"""
|
|
||||||
|
|
||||||
import os, sys
|
|
||||||
sys.path = [os.path.dirname(__file__)] + sys.path
|
|
||||||
|
|
||||||
import config
|
|
||||||
from scopes.storage.db import postgres
|
|
||||||
|
|
||||||
config.dbengine = 'postgresql+psycopg'
|
|
||||||
config.dbname = 'testdb'
|
|
||||||
config.dbuser = 'testuser'
|
|
||||||
config.dbpassword = 'secret'
|
|
||||||
config.dbschema = 'testing'
|
|
||||||
config.StorageFactory = postgres.StorageFactory
|
|
||||||
|
|
||||||
|
|
||||||
33
scopes/tests.py
Normal file
33
scopes/tests.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# scopes/tests.py
|
||||||
|
|
||||||
|
"""The real test implementations"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from scopes import tlib_storage
|
||||||
|
|
||||||
|
import config
|
||||||
|
config.dbengine = 'postgresql'
|
||||||
|
config.dbname = 'ccotest'
|
||||||
|
config.dbuser = 'ccotest'
|
||||||
|
config.dbpassword = 'cco'
|
||||||
|
config.dbschema = 'testing'
|
||||||
|
|
||||||
|
# PostgreSQL-specific settings
|
||||||
|
from scopes.storage.db.postgres import StorageFactory
|
||||||
|
config.storageFactory = StorageFactory(config)
|
||||||
|
#storage = factory(schema='testing')
|
||||||
|
|
||||||
|
|
||||||
|
class Test(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_001_tracking(self):
|
||||||
|
tlib_storage.test_tracking(self, config)
|
||||||
|
|
||||||
|
def test_002_folder(self):
|
||||||
|
tlib_storage.test_folder(self, config)
|
||||||
|
|
||||||
|
def test_003_type(self):
|
||||||
|
tlib_storage.test_type(self, config)
|
||||||
|
|
||||||
|
def test_004_topic(self):
|
||||||
|
tlib_storage.test_topic(self, config)
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# py-scopes/tests
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
# py-scopes/tests/config.py
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file='.env')
|
|
||||||
|
|
||||||
base_url: str = 'https://test.cy7.eu'
|
|
||||||
|
|
||||||
log_file: str = 'scopes/tests/log/scopes-test.log'
|
|
||||||
log_level: str = 'INFO'
|
|
||||||
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
|
|
||||||
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
|
|
||||||
|
|
||||||
|
|
||||||
dbengine: str = 'sqlite'
|
|
||||||
dbname: str = 'var/test.db'
|
|
||||||
dbuser: str | None = None
|
|
||||||
dbpassword: str | None = None
|
|
||||||
dbschema: str | None = None
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
from os import getenv
|
|
||||||
import sys
|
|
||||||
|
|
||||||
base_url = settings.base_url
|
|
||||||
|
|
||||||
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
|
|
||||||
format=settings.log_format, datefmt=settings.log_dateformat)
|
|
||||||
|
|
||||||
# SQLite storage settings
|
|
||||||
dbengine = settings.dbengine
|
|
||||||
dbname = settings.dbname
|
|
||||||
dbuser = settings.dbuser
|
|
||||||
dbpassword = settings.dbpassword
|
|
||||||
dbschema = settings.dbschema
|
|
||||||
|
|
||||||
# special testing stuff
|
|
||||||
from scopes.tests import data_auth # add oidc URIs and keys to dummy_requests data
|
|
||||||
from scopes.tests import dummy_requests
|
|
||||||
sys.modules['requests'] = dummy_requests
|
|
||||||
|
|
||||||
# authentication settings
|
|
||||||
oidc_provider = 'test://oidc'
|
|
||||||
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
|
|
||||||
oidc_params = dict(
|
|
||||||
op_config_url=oidc_provider + '/.well-known/openid-configuration',
|
|
||||||
op_uris=None,
|
|
||||||
op_keys=None,
|
|
||||||
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
|
|
||||||
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
|
|
||||||
client_id=oidc_client_id,
|
|
||||||
principal_prefix=getenv('OIDC_PRINCIPAL_PREFIX', 'loops.'),
|
|
||||||
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
|
|
||||||
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
|
|
||||||
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
|
|
||||||
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
|
|
||||||
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE',
|
|
||||||
'scopes/tests/test-private-key.json'),
|
|
||||||
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
|
|
||||||
project_id=getenv('OIDC_PROJECT_ID', None),
|
|
||||||
)
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
# scopes.tests.data_auth
|
|
||||||
|
|
||||||
"""provide response data for testing (via dummy_requests)"""
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
||||||
from scopes import util
|
|
||||||
|
|
||||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
public_key_n = util.b64e(public_key.public_numbers().n.to_bytes(256)).decode('ASCII')
|
|
||||||
|
|
||||||
oidc_data = {
|
|
||||||
'test://oidc/.well-known/openid-configuration': {
|
|
||||||
"issuer": "test://oidc",
|
|
||||||
"authorization_endpoint": "test://oidc/oauth/v2/authorize",
|
|
||||||
"token_endpoint": "test://oidc/oauth/v2/token",
|
|
||||||
"introspection_endpoint": "test://oidc/oauth/v2/introspect",
|
|
||||||
"userinfo_endpoint": "test://oidc/oidc/v1/userinfo",
|
|
||||||
"revocation_endpoint": "test://oidc/oauth/v2/revoke",
|
|
||||||
"end_session_endpoint": "test://oidc/oidc/v1/end_session",
|
|
||||||
"device_authorization_endpoint": "test://oidc/oauth/v2/device_authorization",
|
|
||||||
"jwks_uri": "test://oidc/oauth/v2/keys"},
|
|
||||||
'test://oidc/oauth/v2/keys': { "keys": [
|
|
||||||
{"use": "sig",
|
|
||||||
"kty": "RSA",
|
|
||||||
"kid": "316766976250797901",
|
|
||||||
"alg": "RS256",
|
|
||||||
"n": public_key_n,
|
|
||||||
"e": "AQAB"}]},
|
|
||||||
'test://oidc/oauth/v2/token': {
|
|
||||||
"access_token": "abcde12345"},
|
|
||||||
'test://oidc/v2/users/human': {
|
|
||||||
"code": 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
from scopes.tests.dummy_requests import response_data
|
|
||||||
response_data.update(oidc_data)
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# scopes.tests.requests
|
|
||||||
|
|
||||||
"""Dummy requests implementation for testing."""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
logger = getLogger('tests.dummy_requests')
|
|
||||||
|
|
||||||
def get(url, *args, **kw):
|
|
||||||
logger.info(f'get: %s - %s - %s', url, args, kw)
|
|
||||||
return FakeResponse(response_data[url])
|
|
||||||
|
|
||||||
def post(url, *args, **kw):
|
|
||||||
logger.info(f'post: %s - %s - %s', url, args, kw)
|
|
||||||
return FakeResponse(response_data[url])
|
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
|
|
||||||
def __init__(self, data):
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
@property
|
|
||||||
def status_code(self):
|
|
||||||
return 200
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return self.data
|
|
||||||
|
|
||||||
|
|
||||||
response_data = {}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
directory for logfiles created by tests
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# scopes.tests.scratch
|
|
||||||
|
|
||||||
"""for keeping experimental and demonstration code and data (mainly for testing)"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
|
||||||
from cryptography.utils import int_to_bytes
|
|
||||||
from scopes import util
|
|
||||||
|
|
||||||
# generate (and serialize) or load private key
|
|
||||||
# generate public JWKS (key set) from private ky
|
|
||||||
|
|
||||||
#private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
||||||
#pem = private_key.private_bytes(encoding=serialization.Encoding.PEM,
|
|
||||||
# format=serialization.PrivateFormat.PKCS8,
|
|
||||||
# encryption_algorithm=serialization.NoEncryption())
|
|
||||||
|
|
||||||
pem = b'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD1ipW1hnV+OWF/\nE09YEQB9GTwQ0DUhmM1ZtmONV3xp1zrpLsvQtJJ6lZ8rp+ws35HGEEQWuBvgKP+K\nyyhILkgHp5LUtXsIruvo/+20Gbxr3bbIp2Omv8NuUnQIlb0SkZwauz/iLi+dFnoW\nTzkCwRnOPG+xqrSzD4CUCIZc4fU61h457I5PWTgK+bZXlsPytook9HpCIPX+9PaU\n935KHeWLonx3GZWqcqUyHSUn8dgRMypaKO70zCZkZWwDbCkJv7OUuY9QijN9niA+\nX3MFyqf9+WK7/lLnNYimdXk9Pz9Lim7HhoAApoW/0ueUfok54OIzuemTAUaunaN6\nmdOvh54dAgMBAAECggEAAWW5Vx7kAY1Bto104U51EEm519EvhUm6B5T4+VTp5LYK\nF1rEMIFwtBkoFfjzLJFEwEakJ8Hgq80TIRdyJ72r9ACIPGtTlH0Jjg94IqyvwOy6\n23fPEXkrLHR0Z5g8bR+6A95liQrA+tCD1QVFRAWUktlkVXfxRC95TMCA3i7rakGG\no6BbyUD2Hp7IEO5fbfKNiqrUO/LwvLOMBTe3Q9EVv7fblm21HiwWG26RgO29UtFo\nEKcaALSqIYwYCkc9EuLpcGRzLfH1opid3Xtu4+hm5enNgdXZRKr9lSQUCSi9oZRE\npszNGyDsaot2ixwKP3c6Eo3bQKMmXXw8Y3JsNiZzIQKBgQD8QuCjKyAeH0QrhW1Y\nvSmUC3q517WZPRgsEMx917MYn/Gl1ywsROjawP/ClxTZkpXVgALFvrggjB75p/QG\noFamZpf9U95fVivAkANQYmhGmqPVQUeb2vgHQOdPSdvvMoRumcEcfS/dOo9R//s+\nfrBQNYfp/tTttdyM2N0CzsuBzQKBgQD5LjX8SvLPe6q6xJchdExywbTk+DLtiKDs\nT5avaGhOqzYhir6FvEt7Gw74J6IWzIt5X4OteSkpRvj2TN0ik+KAfZOk+v9C5iK7\nxbicWj5XSaPJcYvhxogO7mb8sSrl/cY20HRkCsyQOKeH005PHsGZztZoHrsF6t2X\nbtKgNWp9kQKBgHLDHh09RlxNzx6Zkfh3/k1qt4eKmgQ/5hpN/ioWElVWloHjFSaC\npwi2GuT1BLhC1sWNejVqIaw08vaTMRI+qY0ESYsnN5hZxIfTPJ66VkQgn/4pt6Ex\nCfuKzHCm4la8vcDvVApY7YiQ1pjwguWYjy++WrnahBYs0UyGcG2RlMXVAoGBAJNJ\nf1ubqZ5+yNIQ9gwuRCno2dYl52SESCqmeLlCC7XEegCllCxUuoEP429HbgXv7dlW\nXe0iGvRtISflEyknJNEyaR0xx8RxZ8J6Ar9YkFTkEE44MajIww+gV3ux9Vtw/8LS\nwJmJ0JTHCC++9SDLW0BhBFcTIxVCWKz0MsfECyghAoGAOLm2dF59JiiiynGiWXzJ\nZcbZQStR+ysjLX+RqTATAorhweUiv+HwbB4boWhOnDD7q3t+93GnzeqI9m9Q5yo6\nRTyyNYa4uJXMMarZxi3m+RGvuCC2h+7ja6H6UGg3xrHNwcykJkuRAZvqCN+f6Nxo\njc8o9LMimQyTv+yptrOhi2w=\n-----END PRIVATE KEY-----\n'
|
|
||||||
private_key = serialization.load_pem_private_key(pem, password=None)
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
|
|
||||||
nums = public_key.public_numbers()
|
|
||||||
#e = base64.urlsafe_b64encode(int_to_bytes(nums.e)).rstrip(b'=')
|
|
||||||
#e = base64.urlsafe_b64encode(nums.e.to_bytes(3)).rstrip(b'=')
|
|
||||||
e = b'AQAB'
|
|
||||||
#n = base64.urlsafe_b64encode(int_to_bytes(nums.n)).rstrip(b'=')
|
|
||||||
n = util.b64e(nums.n.to_bytes(256))
|
|
||||||
|
|
||||||
# data from example session with zitadel
|
|
||||||
|
|
||||||
keys_response = {"keys":[{"use":"sig","kty":"RSA","kid":"317781576895225677","alg":"RS256","n":"rBE2kf4S1-uLqTKoRcxmBDZXeNU0PcDZtXX3pYK4hbE_cVNqMnWZQNXh4asLvZ_VpInIRI_gRe5pFfrUfnFppnu7sgUGPHS_i6E1JSDWDDViwU0DbQXzTMA36xb8fI9vm_StrKqQ2snpkV2YprWC597vOa6w8eWMEfwWV39gmpgjyaYfwKi2ldvnn_djYSEyy6Cs8QW9L8uaTKDYUuuIY7iyjTRI7kM1boZcg26WQ4nDgSU8BMEmPGc-h7BHIm3PVEKNBUU3dRIcvIlnAx7AJoqdmUU0BllMGOd-To7PP760Vx4gYBsTFRGaQ_9P7vRApn0Wywnhj0BaVWGyJPkggQ","e":"AQAB"}]}
|
|
||||||
|
|
||||||
token_response = {'access_token': 'WGn4xjOluOgEugwqfxACziBZxAxQ1SKBKU6p27DysWw9slM55Fn7wkz_avpy5BjFR1uU_2rJoQTMNsO8NyxOEkmhbpP9xURUQrLRBiZT', 'token_type': 'Bearer', 'expires_in': 43199, 'id_token': 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjMxNzc4MTU3Njg5NTIyNTY3NyIsInR5cCI6IkpXVCJ9.eyJhbXIiOlsicHdkIl0sImF0X2hhc2giOiJQbC1kMjBvRG9DRHZqTUtGMmF4S3N3IiwiYXVkIjpbIjMxMTYxMzExOTgxNjM5MjUyNSIsIjMxNDQ2OTIwOTA3OTkzMDcwMSIsIjMxMTQ3MjQ1MTc2ODg2ODY4NSJdLCJhdXRoX3RpbWUiOjE3NDU5MTAxNTksImF6cCI6IjMxMTYxMzExOTgxNjM5MjUyNSIsImNsaWVudF9pZCI6IjMxMTYxMzExOTgxNjM5MjUyNSIsImVtYWlsIjoiaGVsbXV0Lm1lcnpAcG9zdGVvLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTc0NTk1MzUwOSwiZmFtaWx5X25hbWUiOiJVc2VyIiwiZ2VuZGVyIjoibWFsZSIsImdpdmVuX25hbWUiOiJUZXN0IiwiaWF0IjoxNzQ1OTEwMzA5LCJpc3MiOiJodHRwczovL2ExLmN5Ny5kZSIsImxvY2FsZSI6ImRlIiwibmFtZSI6IlRlc3QgVXNlciIsIm5vbmNlIjoiMVJGem9RNDkyazNWTFEzcyIsInByZWZlcnJlZF91c2VybmFtZSI6InRzdDkiLCJzaWQiOiJWMV8zMTc3ODQyMjY2MjE2MTE4NTMiLCJzdWIiOiIzMTE4NDY3OTY3Mzk1MzQ2NjkiLCJ1cGRhdGVkX2F0IjoxNzQ1MzA1MTkzLCJ1cm46eml0YWRlbDppYW06b3JnOnByb2plY3Q6MzExNDcyNDUxNzY4ODY4Njg1OnJvbGVzIjp7ImNjLXVzZXIiOnsiMzExNDczNTAyMjc0MjQ4NTI1IjoiY3liZXJjb25jZXB0cy5hMS5jeTcuZGUifX0sInVybjp6aXRhZGVsOmlhbTpvcmc6cHJvamVjdDpyb2xlcyI6eyJjYy11c2VyIjp7IjMxMTQ3MzUwMjI3NDI0ODUyNSI6ImN5YmVyY29uY2VwdHMuYTEuY3k3LmRlIn19LCJ1cm46eml0YWRlbDppYW06dXNlcjpyZXNvdXJjZW93bmVyOmlkIjoiMzExNDczNTAyMjc0MjQ4NTI1IiwidXJuOnppdGFkZWw6aWFtOnVzZXI6cmVzb3VyY2Vvd25lcjpuYW1lIjoiY3liZXJjb25jZXB0cyIsInVybjp6aXRhZGVsOmlhbTp1c2VyOnJlc291cmNlb3duZXI6cHJpbWFyeV9kb21haW4iOiJjeWJlcmNvbmNlcHRzLmExLmN5Ny5kZSJ9.hx8JWXWVPq-WfBx9AMgFRWaEX3CioU8BCZwT3d8YETMejsR33LDsbPfnzO0Dbw885tiyNIsdNRj7lfk5HtuZVWdHx58hnaMw-czGhIfB0GXvuh2UFemBoLNJLajtRvCBi-YIZ_c6TD0Ryd2wn_yQlgY1J2R8xTsBWeKM_SYqlKU20r4r3nsX4krg6Q9x-Kc3k0Rg3lMXoxFYgQSzXrKjxTI3HHb03H4lzgmrdcbQqfbLoHKdvGxzfo-Gf8Rlt9rQ-fpGu7fRfPJQV14SPJ6CQtKP0adq_D2blcp1I1xKXML8TytJa05RjL_l_KH-segFfORtGMc4mYo8JUpMW0KOvg'}
|
|
||||||
|
|
||||||
token_id_claims = {'amr': ['pwd'], 'at_hash': 'Pl-d20oDoCDvjMKF2axKsw', 'aud': ['311613119816392525', '314469209079930701', '311472451768868685'], 'auth_time': 1745910159, 'azp': '311613119816392525', 'client_id': '311613119816392525', 'email': 'helmut.merz@posteo.de', 'email_verified': True, 'exp': 1745953509, 'family_name': 'User', 'gender': 'male', 'given_name': 'Test', 'iat': 1745910309, 'iss': 'https://a1.cy7.de', 'locale': 'de', 'name': 'Test User', 'nonce': '1RFzoQ492k3VLQ3s', 'preferred_username': 'tst9', 'sid': 'V1_317784226621611853', 'sub': '311846796739534669', 'updated_at': 1745305193, 'urn:zitadel:iam:org:project:311472451768868685:roles': {'cc-user': {'311473502274248525': 'cyberconcepts.a1.cy7.de'}}, 'urn:zitadel:iam:org:project:roles': {'cc-user': {'311473502274248525': 'cyberconcepts.a1.cy7.de'}}, 'urn:zitadel:iam:user:resourceowner:id': '311473502274248525', 'urn:zitadel:iam:user:resourceowner:name': 'cyberconcepts', 'urn:zitadel:iam:user:resourceowner:primary_domain': 'cyberconcepts.a1.cy7.de'}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"type":"serviceaccount","keyId":"314794985486606157","key":"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0dC8wcwu6Uefxx/shqsSTk//ATseeCy28RMAEa4NFGj/y8Ju\nOfVUj7pB5+6onjmsBAHXhCJ+fEWWAJdHnbvywrBNNhVx38f8v+90zUP2IzlT1UDp\ncTIYaehnf3+uqwgMcijnYJ6UgaHFMSecxnYD4adnw8J/FEMDgy2N+v5krp989VQ5\nT2kgrkb/l5z8dgLhmmcLKm7YCG1uXXP+g+qzEZ9Uhur5b+czjIalzC/tq2V2JoJB\nooH9w1iaRXRKel7FZPo0YGyQh/0a9Zn5JsXVc3YTHTKh9madr/yQqmk+6siTl/Ou\ntz9mvpY+AfFRaIWikoyB3W9rHd0b6WtQPflEPwIDAQABAoIBAAN64daZC2IlJPpJ\nhkPJjJkt7H3ZvCykGTiwZvzkFSV0hGGdzPQ7JHbp0PQG2lcdf8PlP+zaIZzwDofd\n+nscRe+CuxUdj/D1QTTxxM8uxGNbLQ/JbtXIzezbxPOxa3U8wfAWy5enqbDovPuO\nu6PzCydv/mGZ1T/ByMohNEyocYUP6mupHWwf2hN/lnrL264w8uvNjAw0xDtbtBJN\nX61u6vi/fiY37qKblN3irAePwK4LIhHZZoyJ1HrFYIkFf0Bviuzpw/ASVqbjizPV\nmTxGxghiQacAMvSSe+pcfJ7ip74rCFv7+6pzL+yW8df1lbSM9vS+86SDgY9RCc2E\n3h1/hUECgYEA/WqiWNXey25qCNB6WHo3SU5cZIZVNWzsT1zkwkXOUtEyU0/zEfT+\nEjW/vbxIBgZNV1tX2aXd7Ke5OCoQ1dqLnmDoO5d13xTeaWN3FR8ibTwbaDCwyg5d\njyIXK2k7IwtcpJFgJFGM/6udAdO/bPm1IPEslJXHBqZoGrKb+bTw6N8CgYEA0/RQ\nHtQluQYBtXNzEql0MaxBUxfHkwjL6Yo6dM+EJAomI+cccVy22s+z2aQX5GVQnbzs\nm9BGkJzzn7eGPy3i2LgStqUZ2W7VqfIJNCIDbC7OxBAaszh5/LEgv5pfp1Yr/HIf\nwHZz53rdV8H+oUfMJdlyrRyGOeGIDZCd94nTMKECgYAQOpT9BW1IL+EAgYFkSydh\nPXBzS5sHWdtkVbmcq2XELfuAFF2np73hoqmN2BHwuNSZJJNir9mffzpAW4lKeL16\nPhCBSHjW+Xoo26LTqnPE9RV4Pa4EspjRQsijEhEkdGTRcTHsAYD7Gp1qcYoPy4oK\n+wb02Qau6Vc/ZnLQsgK/lwKBgQDMLSGxUPQ11E95GAnWBF7mKuWSwemC/opQItRF\nClJk1VIAa/W+Tm3nQwYhti0920tZaFEVmAEh9c/KH+S2n+FSm5+LSmgoSNiSqZGs\nIsfhQwXzYQAXfWQlxAukB3X1oNEmkll78Z+dcYIfs8UyYBOMsngBwuSahWOmjZVe\ni+phgQKBgC0ozpbIcNg48M4/Rrev3qJB7XlU74MySsFJdBhlrzmK3+z02bXWbyaJ\nzQLwC6Dorw0PcWAKtcJcbBn6ZAoptcmG6wdQrYk1IC+82TDcNvAFL06y8OXHYLtu\ni5AiE4nK1waoDF/1I66VACyKI6hhISRW3bKaxHhrx5OsGKVurF4R\n-----END RSA PRIVATE KEY-----\n","expirationDate":"9999-12-31T23:59:59Z","userId":"311889729668833101"}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
# scopes.tests.test_postgres
|
|
||||||
|
|
||||||
"""Tests for the 'scopes.storage' package - using PostgreSQL."""
|
|
||||||
|
|
||||||
import os, sys
|
|
||||||
sys.path = [os.path.dirname(__file__)] + sys.path
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from scopes.tests import tlib_storage
|
|
||||||
|
|
||||||
from scopes.tests.util import setup_logging
|
|
||||||
from scopes.storage.db.postgres import StorageFactory
|
|
||||||
import config
|
|
||||||
setup_logging(config)
|
|
||||||
|
|
||||||
config.dbengine = 'postgresql+psycopg'
|
|
||||||
config.dbname = 'testdb'
|
|
||||||
config.dbuser = 'testuser'
|
|
||||||
config.dbpassword = 'secret'
|
|
||||||
config.dbschema = 'testing'
|
|
||||||
config.storageFactory = StorageFactory(config)
|
|
||||||
|
|
||||||
|
|
||||||
class Test(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_001_tracking(self):
|
|
||||||
tlib_storage.test_tracking(self, config)
|
|
||||||
|
|
||||||
def test_002_folder(self):
|
|
||||||
tlib_storage.test_folder(self, config)
|
|
||||||
|
|
||||||
def test_003_type(self):
|
|
||||||
tlib_storage.test_type(self, config)
|
|
||||||
|
|
||||||
def test_004_topic(self):
|
|
||||||
tlib_storage.test_topic(self, config)
|
|
||||||
|
|
||||||
def test_005_message(self):
|
|
||||||
tlib_storage.test_message(self, config)
|
|
||||||
|
|
||||||
def test_suite():
|
|
||||||
return unittest.TestSuite((
|
|
||||||
unittest.TestLoader().loadTestsFromTestCase(Test),
|
|
||||||
))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main(defaultTest='test_suite')
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# scopes.tests.test_standard
|
|
||||||
|
|
||||||
"""Tests for the 'scopes.storage' package."""
|
|
||||||
|
|
||||||
import os, sys
|
|
||||||
sys.path = [os.path.dirname(__file__)] + sys.path
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from scopes.tests import tlib_web, tlib_storage
|
|
||||||
|
|
||||||
from scopes.tests.util import setup_logging
|
|
||||||
from scopes.storage.common import StorageFactory
|
|
||||||
import config
|
|
||||||
setup_logging(config)
|
|
||||||
|
|
||||||
config.dbengine = 'sqlite'
|
|
||||||
config.dbname = 'var/test.db'
|
|
||||||
config.dbschema = None
|
|
||||||
config.storageFactory = StorageFactory(config)
|
|
||||||
|
|
||||||
|
|
||||||
class Test(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_001_tracking(self):
|
|
||||||
tlib_storage.test_tracking(self, config)
|
|
||||||
|
|
||||||
def test_002_folder(self):
|
|
||||||
tlib_storage.test_folder(self, config)
|
|
||||||
|
|
||||||
def test_003_type(self):
|
|
||||||
tlib_storage.test_type(self, config)
|
|
||||||
|
|
||||||
def test_004_topic(self):
|
|
||||||
tlib_storage.test_topic(self, config)
|
|
||||||
|
|
||||||
def test_013_web(self):
|
|
||||||
tlib_web.test_app(self, config)
|
|
||||||
tlib_web.test_auth(self, config)
|
|
||||||
tlib_web.test_user_data(self, config)
|
|
||||||
|
|
||||||
|
|
||||||
def suite():
|
|
||||||
return unittest.TestSuite((
|
|
||||||
unittest.TestLoader().loadTestsFromTestCase(Test),
|
|
||||||
))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main(defaultTest='suite')
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# tests/tlib_web.py
|
|
||||||
|
|
||||||
"""Test implementation for the `scopes.web` package."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from zope.publisher.browser import TestRequest
|
|
||||||
from zope.publisher.publish import publish
|
|
||||||
|
|
||||||
from scopes.web.app import Publication
|
|
||||||
from scopes.storage.folder import Root
|
|
||||||
|
|
||||||
logger = logging.getLogger('tlib_web')
|
|
||||||
|
|
||||||
|
|
||||||
def publishRequest(config, storage, path):
|
|
||||||
appRoot = Root(storage)
|
|
||||||
request = TestRequest(environ=dict(PATH_INFO=path))
|
|
||||||
request.setPublication(Publication(appRoot))
|
|
||||||
request = publish(request, False)
|
|
||||||
return request.response
|
|
||||||
|
|
||||||
|
|
||||||
def test_app(self, config):
|
|
||||||
storage = config.storageFactory(config.dbschema)
|
|
||||||
response = publishRequest(config, storage, '/top')
|
|
||||||
logger.info('test_app: response %s %s', response.getStatus(), response.getHeaders())
|
|
||||||
result = json.loads(response.consumeBody())
|
|
||||||
self.assertEqual(result['items'][0]['head']['name'], 'level2-item1')
|
|
||||||
|
|
||||||
def test_auth(self, config):
|
|
||||||
from scopes.web.auth import oidc
|
|
||||||
oidc.startup() # todo: use generic app.startServices()
|
|
||||||
self.assertEqual(len(config.oidc_params['op_uris']), 8)
|
|
||||||
storage = config.storageFactory(config.dbschema)
|
|
||||||
response = publishRequest(config, storage, '/top/auth/login')
|
|
||||||
headers = dict(response.getHeaders())
|
|
||||||
logger.info('test_auth: response %s %s', response.getStatus(), headers)
|
|
||||||
self.assertEqual(response.getStatus(), 302)
|
|
||||||
uri = config.oidc_params['op_uris']['jwks_uri']
|
|
||||||
keys = oidc.loadOidcKeys(uri)
|
|
||||||
logger.info('test_auth keys: %s', keys)
|
|
||||||
|
|
||||||
def test_user_data(self, config):
|
|
||||||
from scopes.web.auth import user
|
|
||||||
u1 = user.User(login='tst9', email='tst9@example.com',
|
|
||||||
firstName='Test', lastName='User')
|
|
||||||
xu1 = user.ExtUser(u1, idPrefix='test.')
|
|
||||||
xu1.create(updateIfExists=True)
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# scopes.tests.util
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
def setup_logging(config):
|
|
||||||
settings = config.settings
|
|
||||||
hdlr = logging.getLogger().handlers[-1]
|
|
||||||
logging.getLogger().removeHandler(hdlr) # remove NullHandler added by testrunner
|
|
||||||
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
|
|
||||||
format=settings.log_format, datefmt=settings.log_dateformat)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
# tests/tlib_storage.py
|
# scopes/tlib_storage.py
|
||||||
|
|
||||||
"""Test implementation for the `scopes.storage` package."""
|
"""Test implementation for the `scopes.storage` package."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from scopes.storage import concept, folder, message, topic, tracking
|
from scopes.storage import concept, folder, topic, tracking
|
||||||
from scopes.util import keywords as k
|
|
||||||
|
|
||||||
# predefine keywords used as domain, action, class head values in messages
|
|
||||||
k('session system')
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracking(self, config):
|
def test_tracking(self, config):
|
||||||
|
|
@ -86,7 +82,7 @@ def test_type(self, config):
|
||||||
concept.setupCoreTypes(storage)
|
concept.setupCoreTypes(storage)
|
||||||
types = storage.getContainer(concept.Type)
|
types = storage.getContainer(concept.Type)
|
||||||
tps = list(types.query())
|
tps = list(types.query())
|
||||||
self.assertEqual(len(tps), 7)
|
self.assertEqual(len(tps), 6)
|
||||||
|
|
||||||
tfolder = types.queryLast(name='folder')
|
tfolder = types.queryLast(name='folder')
|
||||||
fldrs = list(tfolder.values())
|
fldrs = list(tfolder.values())
|
||||||
|
|
@ -98,8 +94,6 @@ def test_type(self, config):
|
||||||
|
|
||||||
def test_topic(self, config):
|
def test_topic(self, config):
|
||||||
storage = config.storageFactory(config.dbschema)
|
storage = config.storageFactory(config.dbschema)
|
||||||
storage.dropTable('rels')
|
|
||||||
rels = storage.getContainer(concept.Triple)
|
|
||||||
storage.dropTable('topics')
|
storage.dropTable('topics')
|
||||||
topics = storage.getContainer(topic.Topic)
|
topics = storage.getContainer(topic.Topic)
|
||||||
types = storage.getContainer(concept.Type)
|
types = storage.getContainer(concept.Type)
|
||||||
|
|
@ -118,23 +112,10 @@ def test_topic(self, config):
|
||||||
title='Programming Languages',
|
title='Programming Languages',
|
||||||
description='Programming Languages'))
|
description='Programming Languages'))
|
||||||
topics.save(tp_proglang)
|
topics.save(tp_proglang)
|
||||||
#storage.commit() # avoid "database locked" error with sqlite
|
|
||||||
tp_itc.addChild(tp_proglang)
|
tp_itc.addChild(tp_proglang)
|
||||||
|
|
||||||
c = list(tp_itc.children())
|
c = list(tp_itc.children())
|
||||||
self.assertEqual(c[0].name, 'prog_lang')
|
self.assertEqual(c[0].name, 'prog_lang')
|
||||||
|
|
||||||
storage.commit()
|
storage.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_message(self, config):
|
|
||||||
storage = config.storageFactory(config.dbschema)
|
|
||||||
storage.dropTable('messages')
|
|
||||||
messages = storage.create(message.Messages)
|
|
||||||
m01 = message.Message('system', 'data', 'session', 'V1_317784226621611853')
|
|
||||||
m01.update(dict(userid='tst9'))
|
|
||||||
mid01 = messages.save(m01)
|
|
||||||
|
|
||||||
storage.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# scopes.util
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from collections.abc import Iterable
|
|
||||||
import hashlib
|
|
||||||
from secrets import choice
|
|
||||||
import string
|
|
||||||
|
|
||||||
# symbols and keywords
|
|
||||||
|
|
||||||
class Symbol(str):
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f'<{self}>'
|
|
||||||
|
|
||||||
class Symbols:
|
|
||||||
|
|
||||||
def __init__(self, ns=None):
|
|
||||||
self.__prefix = ns + '.' if ns else ''
|
|
||||||
|
|
||||||
def __call__(self, *keys):
|
|
||||||
for segment in keys:
|
|
||||||
for key in segment.split():
|
|
||||||
if not hasattr(self, key):
|
|
||||||
setattr(self, key, Symbol(self.__prefix + key))
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __getattr__(self, k):
|
|
||||||
raise AttributeError(k)
|
|
||||||
|
|
||||||
def __getitem__(self, k):
|
|
||||||
return getattr(self, k)
|
|
||||||
|
|
||||||
keywords = Symbols()
|
|
||||||
|
|
||||||
# list / iterable utilities
|
|
||||||
|
|
||||||
def flatten(*it, ignore_none=False):
|
|
||||||
accu = []
|
|
||||||
def doit(it):
|
|
||||||
if ignore_none and it is None:
|
|
||||||
return
|
|
||||||
if isinstance(it, Iterable):
|
|
||||||
for t in it:
|
|
||||||
doit(t)
|
|
||||||
else:
|
|
||||||
accu.append(it)
|
|
||||||
doit(it)
|
|
||||||
return accu
|
|
||||||
|
|
||||||
# random strings, hashes, encodings
|
|
||||||
# for authentication, encryption, and other purposes
|
|
||||||
|
|
||||||
BASE = string.ascii_letters + string.digits
|
|
||||||
BASE2 = BASE + '-._~'
|
|
||||||
|
|
||||||
def rndstr(size=16):
|
|
||||||
return ''.join([choice(BASE) for _ in range(size)])
|
|
||||||
|
|
||||||
def rndstr2(size=64):
|
|
||||||
return ''.join([choice(BASE2) for _ in range(size)])
|
|
||||||
|
|
||||||
def b64e(b):
|
|
||||||
return base64.urlsafe_b64encode(b).rstrip(b'=')
|
|
||||||
|
|
||||||
def hashS256(s):
|
|
||||||
h = hashlib.sha256(s.encode('ascii')).digest()
|
|
||||||
return b64e(h).decode('ascii')
|
|
||||||
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""package scopes.web"""
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""package scopes.web.auth"""
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
# scopes.web.auth.uidc
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from email.utils import formatdate
|
|
||||||
import json
|
|
||||||
import jwt
|
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
from time import time
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
from zope.authentication.interfaces import IAuthentication, IPrincipal
|
|
||||||
from zope.interface import Attribute, Interface, implementer
|
|
||||||
from zope.publisher.interfaces import Unauthorized
|
|
||||||
from zope.security.interfaces import IGroupAwarePrincipal
|
|
||||||
|
|
||||||
from scopes.web.browser import DefaultView, register
|
|
||||||
from scopes.storage.folder import DummyFolder, Root
|
|
||||||
from scopes import util
|
|
||||||
|
|
||||||
import config
|
|
||||||
|
|
||||||
logger = logging.getLogger('web.auth.oidc')
|
|
||||||
|
|
||||||
|
|
||||||
# OIDC authentication for browser users (principals)
|
|
||||||
|
|
||||||
@implementer(IAuthentication)
|
|
||||||
class OidcAuthentication:
|
|
||||||
|
|
||||||
def __init__(self, baseAuth):
|
|
||||||
self.baseAuth = baseAuth
|
|
||||||
|
|
||||||
def authenticate(self, request):
|
|
||||||
auth = Authenticator(request)
|
|
||||||
prc = auth.authenticate()
|
|
||||||
if prc is None and self.baseAuth is not None:
|
|
||||||
prc = self.baseAuth.authenticate(request)
|
|
||||||
if prc is None:
|
|
||||||
prc = self.unauthenticatedPrincipal()
|
|
||||||
return prc
|
|
||||||
|
|
||||||
def getPrincipal(self, id):
|
|
||||||
if self.baseAuth is not None:
|
|
||||||
return self.baseAuth.getPrincipal(id)
|
|
||||||
|
|
||||||
def getPrincipals(self, s):
|
|
||||||
if self.baseAuth is not None:
|
|
||||||
return self.baseAuth.getPrincipals(s)
|
|
||||||
|
|
||||||
def unauthenticatedPrincipal(self):
|
|
||||||
if self.baseAuth is not None:
|
|
||||||
return self.baseAuth.unauthenticatedPrincipal()
|
|
||||||
|
|
||||||
def unauthorized(self, id, request):
|
|
||||||
if self.baseAuth is not None:
|
|
||||||
return self.baseAuth.unauthorized(id, request)
|
|
||||||
Authenticator(request).login()
|
|
||||||
|
|
||||||
def logout(self, request):
|
|
||||||
Authenticator(request).logout()
|
|
||||||
|
|
||||||
authentication = OidcAuthentication(None)
|
|
||||||
|
|
||||||
|
|
||||||
class IExternalPrincipal(Interface):
|
|
||||||
extUserLink = Attribute('Link to OIDC provider for viewing/editing external user')
|
|
||||||
|
|
||||||
|
|
||||||
@implementer(IGroupAwarePrincipal, IExternalPrincipal)
|
|
||||||
class Principal:
|
|
||||||
|
|
||||||
def __init__(self, id, data):
|
|
||||||
self.id = id
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
@property
|
|
||||||
def title(self):
|
|
||||||
return self.data['name']
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self):
|
|
||||||
return self.data['name']
|
|
||||||
|
|
||||||
@property
|
|
||||||
def groups(self):
|
|
||||||
return self.data.get('groups', [])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extUserLink(self):
|
|
||||||
return config.oidc_provider + '/ui/console/users/me'
|
|
||||||
|
|
||||||
def asDict(self):
|
|
||||||
data = self.data.copy()
|
|
||||||
data['id'] = self.id
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class Authenticator(DummyFolder):
|
|
||||||
|
|
||||||
prefix = 'auth.oidc'
|
|
||||||
|
|
||||||
group_prefix = 'gloops.'
|
|
||||||
|
|
||||||
def __init__(self, request):
|
|
||||||
self.request = request
|
|
||||||
self.params = config.oidc_params
|
|
||||||
self.setCrypt(self.params.get('cookie_crypt'))
|
|
||||||
|
|
||||||
def setCrypt(self, key):
|
|
||||||
self.cookieCrypt = key and Fernet(key) or None
|
|
||||||
|
|
||||||
def authenticate(self):
|
|
||||||
''' return principal or None'''
|
|
||||||
data = self.loadSession()
|
|
||||||
logger.debug('authenticate: %s', data)
|
|
||||||
if data and 'userid' in data:
|
|
||||||
id = data.pop('userid')
|
|
||||||
return Principal(id, data)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def login(self):
|
|
||||||
state = util.rndstr()
|
|
||||||
nonce = util.rndstr()
|
|
||||||
codeVerifier = util.rndstr2()
|
|
||||||
codeChallenge = util.hashS256(codeVerifier)
|
|
||||||
reqUrl = self.request.form.get('camefrom') or config.base_url
|
|
||||||
args = dict(
|
|
||||||
client_id=self.params['client_id'],
|
|
||||||
response_type='code', # 'code id_token token',
|
|
||||||
state=state, nonce=nonce,
|
|
||||||
code_challenge=codeChallenge, code_challenge_method='S256',
|
|
||||||
scope='openid profile email urn:zitadel:iam:user:resourceowner',
|
|
||||||
redirect_uri=self.params['callback_url'],
|
|
||||||
)
|
|
||||||
self.storeSession(dict(state=state, nonce=nonce, request_uri=reqUrl,
|
|
||||||
code_verifier=codeVerifier))
|
|
||||||
authUrl = self.params['op_uris']['authorization_endpoint']
|
|
||||||
loginUrl = '?'.join((authUrl, urlencode(args)))
|
|
||||||
logger.debug('login: URL %s', loginUrl)
|
|
||||||
self.request.response.redirect(loginUrl, trusted=True)
|
|
||||||
|
|
||||||
def callback(self, groupsProvider=None):
|
|
||||||
req = self.request
|
|
||||||
logger.debug('callback: %s %s', self, req.form)
|
|
||||||
sdata = self.loadSession()
|
|
||||||
reqUrl = sdata.get('request_uri') or config.base_url
|
|
||||||
code = req.form['code']
|
|
||||||
# !check state: req.form['state'] == sdata['state']
|
|
||||||
args = dict(
|
|
||||||
grant_type='authorization_code',
|
|
||||||
code=code,
|
|
||||||
redirect_uri=self.params['callback_url'],
|
|
||||||
client_id=self.params['client_id'],
|
|
||||||
code_verifier=sdata['code_verifier']
|
|
||||||
)
|
|
||||||
# !set header: 'Content-Type: application/x-www-form-urlencoded'
|
|
||||||
tokenUrl = self.params['op_uris']['token_endpoint']
|
|
||||||
tokenResponse = requests.post(tokenUrl, data=args)
|
|
||||||
tdata = tokenResponse.json()
|
|
||||||
userData = self.getIdTokenData(tdata['id_token'])
|
|
||||||
userId = userData['sub']
|
|
||||||
if not '.' in userId:
|
|
||||||
userId = (self.params.get('principal_prefix', '') +
|
|
||||||
userData['preferred_username'])
|
|
||||||
groups = userData.get('urn:zitadel:iam:org:project:roles', {})
|
|
||||||
groups = set(self.group_prefix + g for g in groups)
|
|
||||||
if groupsProvider is not None:
|
|
||||||
groups = groups.union(groupsProvider(userId))
|
|
||||||
ndata = dict(
|
|
||||||
userid=userId,
|
|
||||||
name=userData['name'],
|
|
||||||
email=userData['email'],
|
|
||||||
groups=list(groups),
|
|
||||||
access_token=tdata['access_token'],
|
|
||||||
session_id=userData['sid'],
|
|
||||||
)
|
|
||||||
self.storeSession(ndata)
|
|
||||||
logger.debug('callback: session data: %s', ndata)
|
|
||||||
req.response.redirect(reqUrl, trusted=True)
|
|
||||||
|
|
||||||
def logout(self):
|
|
||||||
logoutUrl = self.params['op_uris']['end_session_endpoint']
|
|
||||||
args = dict(
|
|
||||||
client_id=self.params['client_id'],
|
|
||||||
post_logout_redirect_uri=config.oidc_params.get('post_logout_redirect_uri',
|
|
||||||
config.base_url),
|
|
||||||
)
|
|
||||||
logoutUrl = '?'.join((logoutUrl, urlencode(args)))
|
|
||||||
cname = self.params['cookie_name']
|
|
||||||
logger.debug('logout, cookie: %s, url: %s', cname, logoutUrl)
|
|
||||||
self.request.response.expireCookie(cname, path='/')
|
|
||||||
self.request.response.redirect(logoutUrl, trusted=True)
|
|
||||||
|
|
||||||
def storeSession(self, data):
|
|
||||||
lifetime = int(self.params['cookie_lifetime'])
|
|
||||||
options = dict(
|
|
||||||
path='/',
|
|
||||||
expires=formatdate(time() + lifetime, localtime=False, usegmt=True),
|
|
||||||
httponly=True,
|
|
||||||
)
|
|
||||||
options['max-age'] = lifetime
|
|
||||||
domain = self.params['cookie_domain']
|
|
||||||
if domain:
|
|
||||||
options['domain'] = domain
|
|
||||||
name = self.params['cookie_name']
|
|
||||||
value = json.dumps(data)
|
|
||||||
if self.cookieCrypt:
|
|
||||||
value = self.cookieCrypt.encrypt(value.encode('UTF-8')).decode('ASCII')
|
|
||||||
self.request.response.setCookie(name, value, **options)
|
|
||||||
|
|
||||||
def loadSession(self):
|
|
||||||
cookie = self.request.getCookies().get(self.params['cookie_name'])
|
|
||||||
if cookie is None:
|
|
||||||
return {}
|
|
||||||
if self.cookieCrypt:
|
|
||||||
cookie = self.cookieCrypt.decrypt(cookie)
|
|
||||||
# !error check: return None - or raise error?
|
|
||||||
data = json.loads(cookie)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def getIdTokenData(self, token):
|
|
||||||
uri = self.params['op_uris']['jwks_uri']
|
|
||||||
keys = loadOidcKeys(uri)
|
|
||||||
header = jwt.get_unverified_header(token)
|
|
||||||
key = jwt.PyJWK(keys[header['kid']])
|
|
||||||
return jwt.decode(token, key, audience=self.params['client_id'])
|
|
||||||
|
|
||||||
|
|
||||||
@register('auth')
|
|
||||||
def authView(context, request):
|
|
||||||
return Authenticator(request)
|
|
||||||
|
|
||||||
@register('login', Authenticator)
|
|
||||||
def login(context, request):
|
|
||||||
context.login()
|
|
||||||
return DefaultView(context, request)
|
|
||||||
|
|
||||||
@register('callback', Authenticator)
|
|
||||||
def callback(context, request):
|
|
||||||
context.callback()
|
|
||||||
return DefaultView(context, request)
|
|
||||||
|
|
||||||
@register('logout', Authenticator)
|
|
||||||
def logout(context, request):
|
|
||||||
context.logout()
|
|
||||||
return DefaultView(context, request)
|
|
||||||
|
|
||||||
|
|
||||||
def startup():
|
|
||||||
try:
|
|
||||||
loadOidcProviderData()
|
|
||||||
except requests.exceptions.JSONDecodeError as e:
|
|
||||||
logger.error(f'oidc.loadOidcProviderData: {e} - OIDC provider not available!')
|
|
||||||
#app.Publication.registerBeforeTraversal(
|
|
||||||
# lambda req: req.setPrincipal(authentication.authenticate(req))
|
|
||||||
|
|
||||||
oidcProviderUris = ['authorization_endpoint', 'token_endpoint',
|
|
||||||
'introspection_endpoint', 'userinfo_endpoint',
|
|
||||||
'revocation_endpoint', 'end_session_endpoint',
|
|
||||||
'device_authorization_endpoint', 'jwks_uri']
|
|
||||||
|
|
||||||
def loadOidcProviderData(force=False):
|
|
||||||
params = config.oidc_params
|
|
||||||
if force or params.get('op_uris') is None:
|
|
||||||
uris = params['op_uris'] = {}
|
|
||||||
opData = requests.get(params['op_config_url']).json()
|
|
||||||
for key in oidcProviderUris:
|
|
||||||
uris[key] = opData[key]
|
|
||||||
#if force or params.get('op_keys') is None:
|
|
||||||
#params['op_keys'] = requests.get(uris['jwks_uri']).json()['keys']
|
|
||||||
|
|
||||||
def loadOidcKeys(uri):
|
|
||||||
return dict((item['kid'], item) for item in requests.get(uri).json()['keys'])
|
|
||||||
|
|
||||||
|
|
||||||
# service user authentication
|
|
||||||
|
|
||||||
def authenticateClient(paramsName='oidc_params'):
|
|
||||||
loadOidcProviderData()
|
|
||||||
params = getattr(config, paramsName)
|
|
||||||
keyData = loadPrivateKeyData(params['private_key_file'])
|
|
||||||
userId = keyData['userId']
|
|
||||||
keyId = keyData['keyId']
|
|
||||||
key = keyData['key']
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
token_lifetime=params.get('api_token_lifetime', 60)
|
|
||||||
payload = dict(
|
|
||||||
iss=userId, sub=userId, aud=config.oidc_provider,
|
|
||||||
iat=now, exp=now + timedelta(minutes=token_lifetime),
|
|
||||||
)
|
|
||||||
jwToken = jwt.encode(payload, key, algorithm="RS256",
|
|
||||||
headers=dict(alg='RS256', kid=keyId))
|
|
||||||
data = dict(
|
|
||||||
grant_type='urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
||||||
scope=' '.join(('openid', params['op_project_scope'])),
|
|
||||||
assertion=jwToken,
|
|
||||||
)
|
|
||||||
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
|
||||||
url = params['op_uris']['token_endpoint']
|
|
||||||
resp = requests.post(url, data=data, headers=headers)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
#print(resp.text)
|
|
||||||
logger.error('authenticateClient: %s', resp.text)
|
|
||||||
return None
|
|
||||||
tdata = resp.json()
|
|
||||||
#print(tdata)
|
|
||||||
return tdata['access_token']
|
|
||||||
|
|
||||||
def loadPrivateKeyData(fn='.private-key.json'):
|
|
||||||
with open(fn) as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
# scopes.org.user
|
|
||||||
|
|
||||||
"""Basic user account (principal) definitions + access to identity provider."""
|
|
||||||
|
|
||||||
# should be moved to scopes.web.auth
|
|
||||||
# scopes.org: + person
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from scopes.web import client
|
|
||||||
from scopes import util
|
|
||||||
|
|
||||||
import config
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class User:
|
|
||||||
|
|
||||||
login: str
|
|
||||||
email: str
|
|
||||||
hashedPassword: Optional[str] = None
|
|
||||||
firstName: str = ''
|
|
||||||
lastName: str = ''
|
|
||||||
displayName: str = ''
|
|
||||||
groups: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if not self.displayName:
|
|
||||||
self.displayName = ' '.join((self.firstName, self.lastName))
|
|
||||||
|
|
||||||
|
|
||||||
class ExtUser:
|
|
||||||
"""All infos for exchanging user data with an external service.
|
|
||||||
|
|
||||||
This base class implements the zitadel interface (as of version 3.3.2).
|
|
||||||
For other identity providers sublass accordingly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
provider = 'zitatel'
|
|
||||||
endpoints = dict(
|
|
||||||
users_human='v2/users/human',
|
|
||||||
#create_authorization='management/v1/zitadel.authorization.v2beta.AuthorizationService/CreateAuthorization',
|
|
||||||
create_authorization='v2beta/authorizations',
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, user, idPrefix=''):
|
|
||||||
self.user = user
|
|
||||||
self.userId = idPrefix + user.login
|
|
||||||
self.client = client.ApiClient(config.oidc_provider)
|
|
||||||
|
|
||||||
def asDict(self):
|
|
||||||
params = config.oidc_params
|
|
||||||
data = dict(
|
|
||||||
userId=self.userId,
|
|
||||||
username=self.user.login,
|
|
||||||
email=dict(email=self.user.email, isVerified=True),
|
|
||||||
profile=dict(
|
|
||||||
givenName=self.user.firstName,
|
|
||||||
familyName=self.user.lastName,
|
|
||||||
displayName=self.user.displayName,
|
|
||||||
),
|
|
||||||
organization=dict(orgId=params['organization_id']),
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def create(self, updateIfExists=False):
|
|
||||||
data = self.asDict()
|
|
||||||
if self.user.hashedPassword:
|
|
||||||
data['hashedPassword'] = self.user.hashedPassword
|
|
||||||
status, res = self.client.post(self.endpoints['users_human'], data)
|
|
||||||
if status > 201:
|
|
||||||
if updateIfExists:
|
|
||||||
return self.update()
|
|
||||||
return status, res
|
|
||||||
#if self.user.groups:
|
|
||||||
#return self.createGroups()
|
|
||||||
|
|
||||||
def update(self, createIfMissing=False):
|
|
||||||
data = self.asDict()
|
|
||||||
if self.user.hashedPassword:
|
|
||||||
data['password'] = dict(hashedPassword=self.user.hashedPassword)
|
|
||||||
status, res = self.client.put(self.endpoints['users_human'], self.userId, data)
|
|
||||||
if status > 200:
|
|
||||||
if createIfMissing:
|
|
||||||
return self.create()
|
|
||||||
else:
|
|
||||||
return status, res
|
|
||||||
#if self.user.groups:
|
|
||||||
#return self.updateGroups()
|
|
||||||
|
|
||||||
def createGroups(self):
|
|
||||||
data = dict(
|
|
||||||
userId=self.userId,
|
|
||||||
projectId=config.oidc_params['project_id'],
|
|
||||||
roleKeys=self.user.groups,
|
|
||||||
)
|
|
||||||
return self.client.post(self.endpoints['create_authorization'], data)
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# scopes.web.client
|
|
||||||
|
|
||||||
"""Web client functionality: access to web sites, APIs with authentication."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
from scopes.web.auth import oidc
|
|
||||||
|
|
||||||
logger = logging.getLogger('web.client')
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClient:
|
|
||||||
|
|
||||||
def __init__(self, baseUrl, authToken=None):
|
|
||||||
self.baseUrl = baseUrl
|
|
||||||
self.authToken = authToken
|
|
||||||
|
|
||||||
def authentication(self):
|
|
||||||
if self.authToken == None:
|
|
||||||
self.authToken = oidc.authenticateClient()
|
|
||||||
return dict(Authorization=f'Bearer {self.authToken}')
|
|
||||||
|
|
||||||
def post(self, endpoint, data):
|
|
||||||
headers = self.authentication()
|
|
||||||
headers['Content-Type'] = 'application/json'
|
|
||||||
headers['Connect-Protocol-Version'] = '1'
|
|
||||||
# self.makeUrl(endpoint)
|
|
||||||
url = '/'.join((self.baseUrl, endpoint))
|
|
||||||
resp = requests.post(url, json=data, headers=headers)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
logger.error('post %s: %s %s', url, resp.status_code, resp.text)
|
|
||||||
return resp.status_code, resp.json()
|
|
||||||
|
|
||||||
def put(self, endpoint, objId, data):
|
|
||||||
headers = self.authentication()
|
|
||||||
headers['Content-Type'] = 'application/json'
|
|
||||||
# self.makeUrl(endpoint, objId)
|
|
||||||
url = '/'.join((self.baseUrl, endpoint, objId))
|
|
||||||
resp = requests.put(url, json=data, headers=headers)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
logger.error('post %s: %s %s', url, resp.status_code, resp.text)
|
|
||||||
return resp.status_code, resp.json()
|
|
||||||
|
|
||||||
47
setup.py
47
setup.py
|
|
@ -1,4 +1,47 @@
|
||||||
from setuptools import setup
|
from setuptools import setup, find_packages
|
||||||
|
import os
|
||||||
|
|
||||||
setup()
|
version = '2.0'
|
||||||
|
|
||||||
|
long_description = (
|
||||||
|
open('README.md').read()
|
||||||
|
+ '\n' +
|
||||||
|
'Contributors\n'
|
||||||
|
'============\n'
|
||||||
|
+ '\n' +
|
||||||
|
open('CONTRIBUTORS.txt').read()
|
||||||
|
+ '\n' +
|
||||||
|
open('CHANGES.txt').read()
|
||||||
|
+ '\n')
|
||||||
|
|
||||||
|
setup(name='py-scopes',
|
||||||
|
version=version,
|
||||||
|
description="combined triple and event storage for the cco application platform",
|
||||||
|
long_description=long_description,
|
||||||
|
# Get more strings from
|
||||||
|
# http://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||||
|
classifiers=[
|
||||||
|
"Programming Language :: Python",
|
||||||
|
],
|
||||||
|
keywords='',
|
||||||
|
author='cyberconcepts.org team',
|
||||||
|
author_email='team@cyberconcepts.org',
|
||||||
|
url='http://www.cyberconcepts.org',
|
||||||
|
license='MIT',
|
||||||
|
packages=find_packages(),
|
||||||
|
#package_dir = {'': 'src'},
|
||||||
|
#namespace_packages=['cco'],
|
||||||
|
include_package_data=True,
|
||||||
|
zip_safe=False,
|
||||||
|
install_requires=[
|
||||||
|
'setuptools',
|
||||||
|
'transaction',
|
||||||
|
'psycopg2-binary',
|
||||||
|
'SQLAlchemy',
|
||||||
|
'zope.sqlalchemy',
|
||||||
|
# -*- Extra requirements: -*-
|
||||||
|
],
|
||||||
|
entry_points="""
|
||||||
|
# -*- Entry points: -*-
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
# py-scopes/tests/config.py
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file='.env')
|
|
||||||
|
|
||||||
base_url: str = 'https://test.cy7.eu'
|
|
||||||
|
|
||||||
log_file: str = 'scopes/tests/log/scopes-test.log'
|
|
||||||
log_level: str = 'INFO'
|
|
||||||
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
|
|
||||||
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
|
|
||||||
|
|
||||||
|
|
||||||
dbengine: str = 'sqlite'
|
|
||||||
dbname: str = 'var/test.db'
|
|
||||||
dbuser: str | None = None
|
|
||||||
dbpassword: str | None = None
|
|
||||||
dbschema: str | None = None
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
from os import getenv
|
|
||||||
import sys
|
|
||||||
|
|
||||||
base_url = settings.base_url
|
|
||||||
|
|
||||||
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
|
|
||||||
format=settings.log_format, datefmt=settings.log_dateformat)
|
|
||||||
|
|
||||||
# SQLite storage settings
|
|
||||||
dbengine = settings.dbengine
|
|
||||||
dbname = settings.dbname
|
|
||||||
dbuser = settings.dbuser
|
|
||||||
dbpassword = settings.dbpassword
|
|
||||||
dbschema = settings.dbschema
|
|
||||||
|
|
||||||
# special testing stuff
|
|
||||||
from scopes.tests import data_auth # add oidc URIs and keys to dummy_requests data
|
|
||||||
from scopes.tests import dummy_requests
|
|
||||||
sys.modules['requests'] = dummy_requests
|
|
||||||
|
|
||||||
# authentication settings
|
|
||||||
oidc_provider = 'test://oidc'
|
|
||||||
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
|
|
||||||
oidc_params = dict(
|
|
||||||
op_config_url=oidc_provider + '/.well-known/openid-configuration',
|
|
||||||
op_uris=None,
|
|
||||||
op_keys=None,
|
|
||||||
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
|
|
||||||
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
|
|
||||||
client_id=oidc_client_id,
|
|
||||||
principal_prefix=getenv('OIDC_PRINCIPAL_PREFIX', 'loops.'),
|
|
||||||
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
|
|
||||||
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
|
|
||||||
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
|
|
||||||
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
|
|
||||||
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE',
|
|
||||||
'scopes/tests/test-private-key.json'),
|
|
||||||
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
|
|
||||||
project_id=getenv('OIDC_PROJECT_ID', None),
|
|
||||||
)
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# py-scopes: tests.test_api
|
|
||||||
|
|
||||||
"""Tests for the fastapi- (and asyncio-) based stuff"""
|
|
||||||
|
|
||||||
from scopes import testing
|
|
||||||
|
|
||||||
import os, sys
|
|
||||||
|
|
||||||
import config
|
|
||||||
from scopes.storage import tracking
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from httpx2 import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
from scopes.api import main
|
|
||||||
from scopes.api.main import app
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module')
|
|
||||||
def storage():
|
|
||||||
#storage = config.StorageFactory(config)(config.dbschema)
|
|
||||||
storage = main.storage()
|
|
||||||
storage.dropTable('tracks')
|
|
||||||
tracks = storage.create(tracking.Container)
|
|
||||||
tr01 = tracking.Track('t01', 'john')
|
|
||||||
tr01.update(dict(activity='testing'))
|
|
||||||
trid01 = tracks.save(tr01)
|
|
||||||
storage.commit()
|
|
||||||
return storage
|
|
||||||
|
|
||||||
def async_client():
|
|
||||||
return AsyncClient(transport=ASGITransport(app=app), base_url='http://test')
|
|
||||||
|
|
||||||
#@pytest.mark.anyio
|
|
||||||
#async def test_root():
|
|
||||||
#async with async_client() as clt:
|
|
||||||
#response = await clt.get('/')
|
|
||||||
|
|
||||||
def test_root():
|
|
||||||
with TestClient(app) as clt:
|
|
||||||
response = clt.get('/')
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {'Hello': 'World'}
|
|
||||||
|
|
||||||
def test_tracking(storage, caplog):
|
|
||||||
with TestClient(app) as clt:
|
|
||||||
caplog.set_level(config.settings.log_level)
|
|
||||||
tracks = clt.get('/tracks').json()
|
|
||||||
assert len(tracks) == 1
|
|
||||||
tr = tracks[0]
|
|
||||||
tr['timeStamp'] = '...'
|
|
||||||
assert tr == {'uid': 'rec-1', 'head': {'taskId': 't01', 'userName': 'john'},
|
|
||||||
'data': {'activity': 'testing'}, 'timeStamp': '...'}
|
|
||||||
#print('\nLogs: ', caplog.text)
|
|
||||||
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# py-scopes: tests.test_core
|
|
||||||
|
|
||||||
""" Tests for scopes.core modules, including e.g. actor, message"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scopes import core
|
|
||||||
from scopes.core import actor, environ
|
|
||||||
from scopes.core.message import Message
|
|
||||||
from scopes.util import keywords as k
|
|
||||||
k('data', 'hello', 'person', 'show', 'switch', 'quit', 'test')
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def collector():
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def show(msg, coll):
|
|
||||||
coll.append(msg)
|
|
||||||
|
|
||||||
def handle_basic(coll):
|
|
||||||
async def handle(msg):
|
|
||||||
match msg:
|
|
||||||
case k.show: show(msg, coll)
|
|
||||||
case k.switch: return handle_alt(coll)
|
|
||||||
case _: show(msg, coll)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def handle_alt(coll):
|
|
||||||
async def handle(msg):
|
|
||||||
match msg:
|
|
||||||
case k.show: show('switched', coll)
|
|
||||||
case _: await asyncio.sleep(0.01); show(msg, coll)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_actor(collector):
|
|
||||||
mb1 = actor.create(handle_basic(collector))
|
|
||||||
mb2 = actor.create(handle_basic(collector))
|
|
||||||
msgs = [Message(k.test, k.hello), k.show, k.switch, k.show, "actor 1: Goodbye"]
|
|
||||||
await asyncio.gather(*[actor.send(mb1, m) for m in msgs])
|
|
||||||
await actor.send(mb2, "actor 2")
|
|
||||||
await actor.send(mb2, k.show)
|
|
||||||
await actor.send(mb2, k.quit)
|
|
||||||
await actor.send(mb2, k.quit)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
#print(collector)
|
|
||||||
assert len(collector) == 6
|
|
||||||
assert collector[-1] == 'actor 1: Goodbye'
|
|
||||||
|
|
||||||
|
|
||||||
def test_message():
|
|
||||||
msg = Message(k.test, k.data, 'person', '4711',
|
|
||||||
data=dict(firstName='Bob', lastName='Miller'))
|
|
||||||
assert msg.head['class'] is k.person
|
|
||||||
assert msg.addr() == (k.test, k.person, '4711')
|
|
||||||
assert msg.categ() == (k.test, k.person)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def meta_env(collector):
|
|
||||||
async def collect(msg):
|
|
||||||
collector.append(msg)
|
|
||||||
return actor.create(collect)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_core(meta_env, collector):
|
|
||||||
env = environ.create_env(meta_env)
|
|
||||||
zero = await core.create_zero(env)
|
|
||||||
await actor.send(env, Message(k.env, k.hello))
|
|
||||||
await actor.send(zero, Message(k.csys, k.hello))
|
|
||||||
await actor.send(zero, Message(k.csys, k.create,
|
|
||||||
data={k.connect: k.pred, k.address: (1, 1)}))
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
assert len(collector) == 4
|
|
||||||
assert collector[0].action is k.created
|
|
||||||
assert collector[1].domain is k.env
|
|
||||||
assert collector[2].domain is k.csys
|
|
||||||
assert collector[3].action is k.created
|
|
||||||
#print(collector)
|
|
||||||
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# py-scopes: tests.test_util
|
|
||||||
|
|
||||||
"""Tests for the scopes.util package"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scopes.util import keywords as k, flatten, Symbols
|
|
||||||
|
|
||||||
k('dummy other', 'cd', 'new', 'erp')
|
|
||||||
|
|
||||||
dom = Symbols('dom')('crm erp sys')
|
|
||||||
|
|
||||||
def test_keywords():
|
|
||||||
assert k.dummy == 'dummy'
|
|
||||||
assert k.dummy is not 'dummy'
|
|
||||||
assert k.dummy is k.dummy
|
|
||||||
assert k.dummy is not k.other
|
|
||||||
assert getattr(k, "abcd"[-2:]) is k.cd
|
|
||||||
assert k['dummy'] is k.dummy
|
|
||||||
assert k['new'] is k['new']
|
|
||||||
assert dom.erp == 'dom.erp'
|
|
||||||
assert dom.erp is not 'dom.erp'
|
|
||||||
assert repr(dom.erp) == '<dom.erp>'
|
|
||||||
assert dom.erp != k.erp
|
|
||||||
|
|
||||||
def test_flatten():
|
|
||||||
lst = (1, (None, 2), (3, (4, 5)))
|
|
||||||
assert flatten(lst) == [1, None, 2, 3, 4, 5]
|
|
||||||
assert flatten(42, lst, (6, 7), ignore_none=True) == [42, 1, 2, 3, 4, 5, 6, 7]
|
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue