├── .github └── workflows │ └── run_tests.yml ├── LICENSE ├── README.md ├── chope ├── __init__.py ├── css.py ├── element.py ├── functions │ ├── __init__.py │ ├── color.py │ ├── function.py │ ├── shape.py │ └── transform.py └── variable.py ├── pyproject.toml └── tests ├── __init__.py ├── test_css.py ├── test_element.py └── test_function.py /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the "main" branch 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 25 | 26 | # Steps represent a sequence of tasks that will be executed as part of the job 27 | steps: 28 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 29 | - uses: actions/checkout@v3 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install ruff pytest 38 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 39 | - name: Test with pytest 40 | run: | 41 | pytest tests/ 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 hanstjua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chope 2 | 3 | CSS & HTML on Python Easily. 4 | 5 | ![PyPI](https://img.shields.io/pypi/v/chope) 6 | ![Pepy Total Downlods](https://img.shields.io/pepy/dt/chope) 7 | ![GitHub](https://img.shields.io/github/license/hanstjua/chope) 8 | ![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/hanstjua/chope/run_tests.yml?branch=main) 9 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chope?label=python) 10 | 11 | Chope is a library that aims to provide a HTML and CSS domain-specific language (DSL) for Python. 12 | It draws inspiration from Clojure's [Hiccup](https://github.com/weavejester/hiccup) and JavaScript's [Emotion](https://emotion.sh/docs/introduction). 13 | 14 | ## Table of Contents 15 | * [Install](#install) 16 | * [Syntax](#syntax) 17 | * [HTML](#html) 18 | * [Creating Custom Elements](#creating-custom-elements) 19 | * [CSS](#css) 20 | * [Units](#units) 21 | * [Render](#render) 22 | * [Building a Template](#building-a-template) 23 | * [Factory Function](#factory-function) 24 | * [Variable Object](#variable-object) 25 | 26 | 27 | 28 | ## Install 29 | 30 | Chope can be installed through pip. 31 | 32 | `pip install chope` 33 | 34 | 35 | 36 | ## Syntax 37 | 38 | Here is a basic example of Chope syntax: 39 | 40 | ```python 41 | from chope import * 42 | from chope.css import * 43 | 44 | page = html[ 45 | head[ 46 | style[ 47 | Css[ 48 | 'body': dict( 49 | background_color='linen', 50 | font_size=pt/12 51 | ), 52 | '.inner-div': dict( 53 | color='maroon', 54 | margin_left=px/40 55 | ) 56 | ] 57 | ] 58 | ], 59 | body[ 60 | h1['Title'], 61 | div(class_='outer-div')[ 62 | div(class_='inner-div')[ 63 | 'Some content.' 64 | ] 65 | ] 66 | ] 67 | ] 68 | ``` 69 | 70 | 71 | 72 | ### HTML 73 | 74 | Declaring an element is as simple as this: 75 | 76 | ```python 77 | #
content
78 | 79 | div['content'] 80 | ``` 81 | 82 | Element attributes can be specified like so: 83 | 84 | ```python 85 | #
This is key-value style.
86 | 87 | div(id='my-id', class_='my-class your-class')['This is key-value style.'] 88 | ``` 89 | 90 | Notice the `_` suffix in the `class_` attribute. This suffix is necessary for any attribute names that clashes with any Python keyword. 91 | 92 | You can also define `id` and `class` using CSS selector syntax: 93 | 94 | ```python 95 | #
This is selector style.
96 | 97 | div('#my-id.my-class.your-class', title='Title')['This is selector style.'] 98 | ``` 99 | 100 | For attributes with names that cannot be declared using the *key-value* style, you can use the *tuple* style. 101 | 102 | ```python 103 | #
This is tuple style.
104 | 105 | div('my:attr', 'x', 106 | 'ur-attr', 'y', 107 | 'their[attr]', 'z')[ 108 | 'This is tuple style.' 109 | ] 110 | ``` 111 | 112 | The different styles can be mixed as long as there is no duplicate attribute definition. 113 | 114 | ```python 115 | # acceptable mixed style 116 | 117 | div('#my-id.class1.class2', 118 | 'my:attr', 'x', 119 | 'ur-attr', 'y', 120 | 'their[attr]', 'z', 121 | title="Mix 'em up", 122 | subtitle="But don't get mixed up" 123 | )[ 124 | 'This mixed style is OK.' 125 | ] 126 | 127 | # NOT acceptable mixed style 128 | 129 | div('#my-id.class1.class2', 130 | 'id', 'x', # conflicts with 'id' defined in selector style 131 | 'title', 'y', 132 | 'their[attr]', 'z', 133 | title="Mix 'em up", # conflicts with 'title' defined in tuple style 134 | subtitle="But don't get mixed up" 135 | )[ 136 | 'This mixed style is NOT OK.' 137 | ] 138 | ``` 139 | 140 | Iterables can be used to generate a sequence of elements in the body of an element. 141 | 142 | ```python 143 | # 144 | 145 | ul[ 146 | [li[str(i)] for i in range(3)] 147 | ] 148 | ``` 149 | 150 |
151 | 152 | #### Creating Custom Elements 153 | 154 | If you want to create a custom element with a custom tag, simply inherit from the `Element` class. 155 | 156 | ```python 157 | from chope import Element 158 | 159 | 160 | class custom(Element): ## class name will be used as tag name during rendering 161 | pass 162 | 163 | 164 | custom['some content.'] ## some content. 165 | ``` 166 | 167 | Normally, you don't need to override any method of `Element`, but if you want to change how your element is rendered, you can override the `render()` method. 168 | 169 | 170 | 171 | ### CSS 172 | 173 | The CSS syntax in Chope is simply a mapping between CSS selector strings and declarations dictionaries. 174 | 175 | Here's how a simple CSS stylesheet looks like in Chope: 176 | 177 | ```python 178 | ''' 179 | h1 { 180 | color: blue; 181 | } 182 | 183 | .my-class { 184 | background-color: linen; 185 | text-align: center; 186 | } 187 | ''' 188 | 189 | Css[ 190 | 'h1': dict( 191 | color='blue' 192 | ), 193 | '.my-class': dict( 194 | background_color='linen', 195 | text_align='center' 196 | ) 197 | ] 198 | 199 | # OR 200 | 201 | Css[ 202 | 'h1': { 203 | 'color': 'blue' 204 | }, 205 | '.my-class': { 206 | 'background-color': 'linen', 207 | 'text-align': 'center' 208 | } 209 | ] 210 | ``` 211 | 212 | When you do declarations using the `dict` constructor, any `_` will be converted to `-` automatically. 213 | 214 | If your attribute name actually contains an `_`, declare using dictionary literal instead. 215 | 216 | 217 | 218 | #### Units 219 | 220 | Declaring size properties is very simple: 221 | 222 | ```python 223 | ''' 224 | .my-class { 225 | font-size: 14px; 226 | margin: 20%; 227 | } 228 | ''' 229 | 230 | Css[ 231 | '.my-class': dict( 232 | font_size=px/14, 233 | margin=percent/20 234 | ) 235 | ] 236 | ``` 237 | 238 | Chope supports standard HTML units. (e.g.`em`, `rem`, `pt`, etc.) 239 | 240 | To set properties with multiple values, simply pass an iterable or a string. 241 | 242 | ```python 243 | ''' 244 | .my-class { 245 | padding: 58px 0 0; 246 | } 247 | ''' 248 | 249 | Css[ 250 | '.my-class': dict( 251 | padding=(px/58, 0, 0) 252 | ) 253 | ] 254 | 255 | # OR 256 | 257 | Css[ 258 | '.my-class': dict( 259 | padding='58px 0 0' 260 | ) 261 | ] 262 | ``` 263 | 264 | 265 | 266 | ## Render 267 | 268 | Once you are done defining your CSS and HTML, you can render them into string using the `render()` method. 269 | 270 | ```python 271 | >>> page = html[ 272 | head[ 273 | style[ 274 | Css[ 275 | '.item': dict(font_size=px/14) 276 | ] 277 | ] 278 | ], 279 | body[ 280 | div('#my-item.item')['My content.'] 281 | ] 282 | ] 283 | >>> print(page.render()) 284 | ' 285 | 286 | 291 | 292 | 293 |
294 | My content. 295 |
296 | 297 | ' 298 | ``` 299 | 300 | By default, `render()` will add indentations with 2 spaces. You can modify this using the `indent` keyword argument. 301 | 302 | ```python 303 | >>> print(page.render(indent=4)) 304 | ' 305 | 306 | 311 | 312 | 313 |
314 | My content. 315 |
316 | 317 | ' 318 | >>> print(page.render(indent=0)) ## render flat string 319 | '
My content.
' 320 | ``` 321 | 322 | CSS objects can also be rendered the same way. 323 | 324 | ```python 325 | >>> css = Css[ 326 | 'h1': dict(font_size=px/14), 327 | '.my-class': dict( 328 | color='blue', 329 | padding=(0,0,px/20) 330 | ) 331 | ] 332 | >>> print(css.render()) 333 | 'h1 { 334 | font-size: 14px; 335 | } 336 | 337 | .my-class { 338 | color: blue; 339 | padding: 0 0 20px; 340 | }' 341 | ``` 342 | 343 |
344 | 345 | ## Building a Template 346 | 347 | There are different ways you can construct a HTML template with `chope`, two of which are *Factory Function* and *Variable Object*. 348 | 349 | 350 | 351 | ### Factory Function 352 | 353 | Factory function is probably the simplest way to build reusable templates. 354 | 355 | ```python 356 | def my_list(title: str, items: List[str], ordered: bool = False) -> div: 357 | list_tag = ol if ordered else ul 358 | return div[ 359 | f'{title}
', 360 | list_tag[ 361 | [li[i] for i in items] 362 | ] 363 | ] 364 | 365 | def my_content(items: List[Component], attrs: dict) -> div: 366 | return div(**attrs)[ 367 | items 368 | ] 369 | 370 | result = my_content( 371 | [ 372 | my_list('Grocery', ['Soap', 'Shampoo', 'Carrots']), 373 | my_list( 374 | 'Egg Cooking', 375 | ['Crack egg.', 'Fry egg.', 'Eat egg.'], 376 | ordered=True 377 | ) 378 | ], 379 | { 380 | 'id': 'my-content', 381 | 'class': 'list styled-list' 382 | } 383 | ) 384 | ``` 385 | 386 | Factory function is a simple, elegant solution to construct a group of small, independent reusable templates. However, when your templates group grows in size and complexity, the factory functions can get unwieldy, as we will see at the end of the next section. 387 | 388 |
389 | 390 | ### Variable Object 391 | 392 | Another way to build a HTML template is to use the *Variable Object*, `Var`. 393 | 394 | 395 | ```python 396 | from chope import * 397 | from chope.variable import Var 398 | 399 | # declaring element with Var content 400 | template = html[ 401 | div[Var('my-content')] 402 | ] 403 | 404 | # setting value to Var object 405 | 406 | final_html = template.set_vars({'my-content': 'This is my content.'}) ## dict style 407 | 408 | ## OR 409 | 410 | final_html = template.set_vars(my_content='This is my content.') ## key-value style 411 | 412 | print(final_html.render(indent=0)) ##
This is my content.
413 | ``` 414 | 415 | You can combine both _dict_ and _key-value_ style when setting variable values, but note that **values defined using _kwargs_ take priority over those defined using _dict_**. 416 | 417 | A variable object can have a default value. 418 | 419 | ```python 420 | >>> print(div[Var('content', 'This is default content.')].render(indent=0)) 421 | '
This is default content.
' 422 | ``` 423 | 424 | A variable object's value can be set to an element. 425 | 426 | ```python 427 | >>> content = div[Var('inner')] 428 | >>> new_content = content.set_vars(inner=div['This is inner content.']) 429 | >>> print(new_content.render()) 430 | '
431 |
432 | This is inner content. 433 |
434 |
' 435 | ``` 436 | 437 | `Var` works in an element attribute as well. 438 | 439 | ```python 440 | >>> content = div(name=Var('name'))['My content.'] 441 | >>> new_content = content.set_vars(name='my-content') 442 | >>> print(new_content.render()) 443 | '
444 | My content. 445 |
' 446 | ``` 447 | 448 | You can use `Var` in CSS too. 449 | 450 | ```python 451 | >>> css = Css[ 452 | # CSS rule as a variable 453 | 'h1': dict(font_size=Var('h1.font-size')), 454 | 455 | # CSS declaration as a variable 456 | '.my-class': Var('my-class') 457 | ] 458 | >>> new_css = css.set_vars({'h1.font-size': px/1, 'my-class': {'color': 'blue'}}) 459 | >>> print(new_css.render()) 460 | 'h1 { 461 | font-size: 1px; 462 | } 463 | 464 | .my-class { 465 | color: blue; 466 | }' 467 | ``` 468 | 469 | The `set` of all variable names in an element/CSS can be retrieved using the `get_vars()` method. 470 | 471 | ```python 472 | >>> template = html[ 473 | style[ 474 | Css[ 475 | 'h1': dict(font_size=Var('css.h1.font-size')) 476 | ] 477 | ], 478 | div[ 479 | Var('main-content'), 480 | div[ 481 | Var('inner-content') 482 | ] 483 | ] 484 | ] 485 | >>> print(template.get_vars()) 486 | {'main-content', 'inner-content', 'css.h1.font-size'} 487 | ``` 488 | 489 | An advantage of using variable object is that it allows for easy deferment of variable value settings, which makes combining templates simple. 490 | 491 | ```python 492 | navs_template = ul('.nav')[ 493 | Var('navs.items') 494 | ] 495 | 496 | pagination_template = nav[ 497 | ul('.pagination')[ 498 | li('.page-item', class_=Var( 499 | 'pagination.previous.disabled', 500 | 'disabled' 501 | ))['Previous'], 502 | Var('nav.pages'), 503 | li('.page-item', class_=Var( 504 | 'pagination.next.disabled', 505 | 'disabled' 506 | ))['Next'] 507 | ] 508 | ] 509 | 510 | body_template = body[ 511 | navs_template, 512 | div('.main-content')[Var('body.main-content')], 513 | pagination_template 514 | ] 515 | ``` 516 | 517 | Compare that to the equivalent factory function implementation. 518 | 519 | ```python 520 | def navs_template(items: List[li]) -> ul: 521 | return ul('.nav')[ 522 | items 523 | ] 524 | 525 | def pagination_template( 526 | pages: List[li], 527 | previous_disabled: bool = True, 528 | next_disabled: bool = True 529 | ) -> nav: 530 | return nav[ 531 | ul('.pagination')[ 532 | li(f'.page-item{" disabled" if previous_disabled else ""}')['Previous'], 533 | pages, 534 | li(f'.page-item{" disabled" if next_disabled else ""}')['Next'] 535 | ] 536 | ] 537 | 538 | def body_template( 539 | navs_items: List[li], 540 | pagination_pages: List[li], 541 | body_main_content: Element, 542 | pagination_previous_disabled: bool = True, 543 | pagination_next_disabled: bool = True 544 | ) -> body: 545 | return body[ 546 | navs_template(navs_items), 547 | body_main_content, 548 | pagination_template( 549 | pagination_pages, 550 | pagination_previous_disabled, 551 | pagination_next_disabled 552 | ) 553 | ] 554 | ``` 555 | 556 | As you may have observed, the number of parameters for upstream template's factory function can easily explode when you start combining more downstream templates. 557 | -------------------------------------------------------------------------------- /chope/__init__.py: -------------------------------------------------------------------------------- 1 | from chope.element import Element 2 | 3 | 4 | class a(Element): 5 | pass 6 | 7 | 8 | class abbr(Element): 9 | pass 10 | 11 | 12 | class acronym(Element): 13 | pass 14 | 15 | 16 | class address(Element): 17 | pass 18 | 19 | 20 | class applet(Element): 21 | pass 22 | 23 | 24 | class area(Element): 25 | pass 26 | 27 | 28 | class article(Element): 29 | pass 30 | 31 | 32 | class aside(Element): 33 | pass 34 | 35 | 36 | class audio(Element): 37 | pass 38 | 39 | 40 | class b(Element): 41 | pass 42 | 43 | 44 | class base(Element): 45 | pass 46 | 47 | 48 | class basefont(Element): 49 | pass 50 | 51 | 52 | class bdi(Element): 53 | pass 54 | 55 | 56 | class bdo(Element): 57 | pass 58 | 59 | 60 | class big(Element): 61 | pass 62 | 63 | 64 | class blockquote(Element): 65 | pass 66 | 67 | 68 | class body(Element): 69 | pass 70 | 71 | 72 | class br(Element): 73 | pass 74 | 75 | 76 | class button(Element): 77 | pass 78 | 79 | 80 | class canvas(Element): 81 | pass 82 | 83 | 84 | class caption(Element): 85 | pass 86 | 87 | 88 | class center(Element): 89 | pass 90 | 91 | 92 | class cite(Element): 93 | pass 94 | 95 | 96 | class code(Element): 97 | pass 98 | 99 | 100 | class col(Element): 101 | pass 102 | 103 | 104 | class colgroup(Element): 105 | pass 106 | 107 | 108 | class data(Element): 109 | pass 110 | 111 | 112 | class datalist(Element): 113 | pass 114 | 115 | 116 | class dd(Element): 117 | pass 118 | 119 | 120 | class details(Element): 121 | pass 122 | 123 | 124 | class dfn(Element): 125 | pass 126 | 127 | 128 | class dialog(Element): 129 | pass 130 | 131 | 132 | class dir(Element): 133 | pass 134 | 135 | 136 | class div(Element): 137 | pass 138 | 139 | 140 | class dl(Element): 141 | pass 142 | 143 | 144 | class dt(Element): 145 | pass 146 | 147 | 148 | class em(Element): 149 | pass 150 | 151 | 152 | class embed(Element): 153 | pass 154 | 155 | 156 | class fieldset(Element): 157 | pass 158 | 159 | 160 | class figcaption(Element): 161 | pass 162 | 163 | 164 | class figure(Element): 165 | pass 166 | 167 | 168 | class font(Element): 169 | pass 170 | 171 | 172 | class footer(Element): 173 | pass 174 | 175 | 176 | class form(Element): 177 | pass 178 | 179 | 180 | class frame(Element): 181 | pass 182 | 183 | 184 | class frameset(Element): 185 | pass 186 | 187 | 188 | class h1(Element): 189 | pass 190 | 191 | 192 | class h2(Element): 193 | pass 194 | 195 | 196 | class h3(Element): 197 | pass 198 | 199 | 200 | class h4(Element): 201 | pass 202 | 203 | 204 | class h5(Element): 205 | pass 206 | 207 | 208 | class h6(Element): 209 | pass 210 | 211 | 212 | class head(Element): 213 | pass 214 | 215 | 216 | class header(Element): 217 | pass 218 | 219 | 220 | class hr(Element): 221 | pass 222 | 223 | 224 | class html(Element): 225 | pass 226 | 227 | 228 | class i(Element): 229 | pass 230 | 231 | 232 | class iframe(Element): 233 | pass 234 | 235 | 236 | class img(Element): 237 | pass 238 | 239 | 240 | class input(Element): 241 | pass 242 | 243 | 244 | class ins(Element): 245 | pass 246 | 247 | 248 | class kbd(Element): 249 | pass 250 | 251 | 252 | class label(Element): 253 | pass 254 | 255 | 256 | class legend(Element): 257 | pass 258 | 259 | 260 | class li(Element): 261 | pass 262 | 263 | 264 | class link(Element): 265 | pass 266 | 267 | 268 | class main(Element): 269 | pass 270 | 271 | 272 | class map(Element): 273 | pass 274 | 275 | 276 | class mark(Element): 277 | pass 278 | 279 | 280 | class meta(Element): 281 | pass 282 | 283 | 284 | class meter(Element): 285 | pass 286 | 287 | 288 | class nav(Element): 289 | pass 290 | 291 | 292 | class noframes(Element): 293 | pass 294 | 295 | 296 | class noscript(Element): 297 | pass 298 | 299 | 300 | class object(Element): 301 | pass 302 | 303 | 304 | class ol(Element): 305 | pass 306 | 307 | 308 | class optgroup(Element): 309 | pass 310 | 311 | 312 | class option(Element): 313 | pass 314 | 315 | 316 | class output(Element): 317 | pass 318 | 319 | 320 | class p(Element): 321 | pass 322 | 323 | 324 | class param(Element): 325 | pass 326 | 327 | 328 | class picture(Element): 329 | pass 330 | 331 | 332 | class pre(Element): 333 | pass 334 | 335 | 336 | class progress(Element): 337 | pass 338 | 339 | 340 | class q(Element): 341 | pass 342 | 343 | 344 | class rp(Element): 345 | pass 346 | 347 | 348 | class rt(Element): 349 | pass 350 | 351 | 352 | class ruby(Element): 353 | pass 354 | 355 | 356 | class s(Element): 357 | pass 358 | 359 | 360 | class samp(Element): 361 | pass 362 | 363 | 364 | class script(Element): 365 | pass 366 | 367 | 368 | class section(Element): 369 | pass 370 | 371 | 372 | class select(Element): 373 | pass 374 | 375 | 376 | class small(Element): 377 | pass 378 | 379 | 380 | class source(Element): 381 | pass 382 | 383 | 384 | class span(Element): 385 | pass 386 | 387 | 388 | class strike(Element): 389 | pass 390 | 391 | 392 | class strong(Element): 393 | pass 394 | 395 | 396 | class style(Element): 397 | pass 398 | 399 | 400 | class sub(Element): 401 | pass 402 | 403 | 404 | class summary(Element): 405 | pass 406 | 407 | 408 | class sup(Element): 409 | pass 410 | 411 | 412 | class svg(Element): 413 | pass 414 | 415 | 416 | class table(Element): 417 | pass 418 | 419 | 420 | class tbody(Element): 421 | pass 422 | 423 | 424 | class td(Element): 425 | pass 426 | 427 | 428 | class template(Element): 429 | pass 430 | 431 | 432 | class textarea(Element): 433 | pass 434 | 435 | 436 | class tfoot(Element): 437 | pass 438 | 439 | 440 | class th(Element): 441 | pass 442 | 443 | 444 | class thead(Element): 445 | pass 446 | 447 | 448 | class time(Element): 449 | pass 450 | 451 | 452 | class title(Element): 453 | pass 454 | 455 | 456 | class tr(Element): 457 | pass 458 | 459 | 460 | class track(Element): 461 | pass 462 | 463 | 464 | class tt(Element): 465 | pass 466 | 467 | 468 | class u(Element): 469 | pass 470 | 471 | 472 | class ul(Element): 473 | pass 474 | 475 | 476 | class var(Element): 477 | pass 478 | 479 | 480 | class video(Element): 481 | pass 482 | 483 | 484 | class wbr(Element): 485 | pass 486 | -------------------------------------------------------------------------------- /chope/css.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from itertools import chain 3 | from typing import Any, Dict, Iterable, List, Set, Union 4 | 5 | from chope.variable import Var 6 | 7 | 8 | class RenderError(Exception): 9 | pass 10 | 11 | 12 | class Rule: 13 | def __init__(self, name: str, declarations: List[dict]): 14 | self.__declarations = declarations 15 | self.__name = name 16 | 17 | def __eq__(self, __value: object) -> bool: 18 | return ( 19 | isinstance(__value, Rule) 20 | and self.__declarations == __value.__declarations 21 | and self.__name == __value.__name 22 | ) 23 | 24 | def render(self, indent: int = 2) -> str: 25 | nl = "\n" 26 | indented = indent > 0 27 | declarations_str = "" 28 | 29 | def get_value(var: Any) -> Any: 30 | if isinstance(var, Dict): 31 | return var 32 | elif isinstance(var, Var): 33 | return get_value(var.value) 34 | else: 35 | return var 36 | 37 | declarations = get_value(self.__declarations) 38 | 39 | if not isinstance(declarations, dict): 40 | raise RenderError( 41 | f"Invalid declaration {declarations} in rule '{self.__name}'. Declarations must be a dict object." 42 | ) 43 | 44 | for property, value in declarations.items(): 45 | value = get_value(value) 46 | if isinstance(value, Iterable) and not isinstance(value, str): 47 | value = " ".join(value) 48 | 49 | declarations_str += ( 50 | f'{" " * indent}{property.replace("_", "-")}: {value};{nl * indented}' 51 | ) 52 | 53 | return f"{self.__name} {{{nl * indented}{declarations_str}}}" 54 | 55 | def get_vars(self) -> Set[str]: 56 | def get_var(var: Var) -> Set[str]: 57 | if isinstance(var.value, Var): 58 | return {var.name}.union(get_var(var.value)) 59 | else: 60 | return {var.name} 61 | 62 | if isinstance(self.__declarations, Var): 63 | return get_var(self.__declarations) 64 | elif isinstance(self.__declarations, dict): 65 | return reduce( 66 | lambda out, s: out.union(s), 67 | (get_var(d) for d in self.__declarations.values()), 68 | set(), 69 | ) 70 | else: 71 | return set() 72 | 73 | def set_vars(self, values: Dict[str, Any]) -> "Rule": 74 | def set_var(comp: Any, values: Dict[str, Any]) -> Any: 75 | if isinstance(comp, Var): 76 | new_var = comp.set_value(values) 77 | if new_var == comp and isinstance(new_var.value, Var): 78 | return Var(new_var.name, new_var.value.set_vars(values)) 79 | else: 80 | return new_var 81 | else: 82 | return comp 83 | 84 | if isinstance(self.__declarations, dict): 85 | new_declarations = { 86 | attr: set_var(value, values) 87 | for attr, value in self.__declarations.items() 88 | } 89 | elif isinstance(self.__declarations, Var): 90 | old_var = self.__declarations 91 | new_var = set_var(old_var, values) 92 | new_declarations = ( 93 | self if new_var == old_var else Var(old_var.name, new_var.value) 94 | ) 95 | else: 96 | new_declarations = self.__declarations 97 | 98 | return ( 99 | self 100 | if new_declarations == self.__declarations 101 | else Rule(self.__name, new_declarations) 102 | ) 103 | 104 | 105 | class Css: 106 | def __init__(self, rules: List[Rule]): 107 | self._rules = rules 108 | 109 | def __eq__(self, __value: object) -> bool: 110 | return isinstance(__value, Css) and self._rules == __value._rules 111 | 112 | def __class_getitem__(cls, items: Union[slice, Iterable[slice]]) -> "Css": 113 | if isinstance(items, slice): 114 | rules = [Rule(items.start.replace("_", "-"), items.stop)] 115 | else: 116 | rules = [Rule(item.start.replace("_", "-"), item.stop) for item in items] 117 | 118 | return cls(rules) 119 | 120 | def render(self, indent: int = 2) -> str: 121 | indented = indent > 0 122 | return ("\n\n" * indented).join((rule.render(indent) for rule in self._rules)) 123 | 124 | def get_vars(self) -> Set[str]: 125 | return reduce( 126 | lambda out, s: out.union(s), 127 | (rule.get_vars() for rule in self._rules), 128 | set(), 129 | ) 130 | 131 | def set_vars(self, values_: Dict[str, Any], **kwargs) -> "Css": 132 | combined_values = {k: v for k, v in chain(values_.items(), kwargs.items())} 133 | 134 | # shoutout to Dua Lipa 135 | new_rules = [rule.set_vars(combined_values) for rule in self._rules] 136 | return self if new_rules == self._rules else Css(new_rules) 137 | 138 | 139 | class Unit: 140 | def __init__(self, name: str): 141 | self.__name = name 142 | 143 | def __truediv__(self, value) -> str: 144 | return str(value) + self.__name 145 | 146 | 147 | cm = Unit("cm") 148 | ch = Unit("ch") 149 | em = Unit("em") 150 | ex = Unit("ex") 151 | in_ = Unit("in") 152 | mm = Unit("mm") 153 | pc = Unit("pc") 154 | percent = Unit("%") 155 | pt = Unit("pt") 156 | px = Unit("px") 157 | rem = Unit("rem") 158 | vh = Unit("vh") 159 | vmax = Unit("vmax") 160 | vmin = Unit("vmin") 161 | vw = Unit("vw") 162 | -------------------------------------------------------------------------------- /chope/element.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import reduce 3 | from itertools import chain 4 | from typing import Any, Dict, Iterable, Set, Tuple, Union 5 | 6 | from chope.css import Css 7 | from chope.variable import Var 8 | 9 | 10 | class DuplicateAttributeError(Exception): 11 | pass 12 | 13 | 14 | class Element: 15 | def __init__(self, *args, **kwargs): 16 | self._components: Tuple[Component, ...] = () 17 | 18 | self._classes = "" 19 | self._id = "" 20 | self._attributes = {} 21 | 22 | if args: 23 | selector_id, selector_classes = ( 24 | self.__get_id_classes_from_selector(args[0]) 25 | if len(args) % 2 26 | else ("", "") 27 | ) 28 | self._classes = selector_classes 29 | 30 | tuple_id, tuple_classes, tuple_attrs = ( 31 | self.__get_id_classes_attrs_from_tuple(args[len(args) % 2 :]) 32 | ) 33 | 34 | if selector_id and tuple_id: 35 | raise DuplicateAttributeError( 36 | f"id declared twice: id={selector_id} and id={tuple_id}" 37 | ) 38 | 39 | self._id = selector_id or tuple_id 40 | self._classes = ( 41 | f"{self._classes} {tuple_classes}".strip() 42 | if tuple_classes 43 | else self._classes 44 | ) 45 | 46 | self._attributes.update(tuple_attrs) 47 | 48 | if self._id and "id" in kwargs: 49 | raise DuplicateAttributeError( 50 | f'id declared twice: id={self._id} and id={kwargs["id"]}' 51 | ) 52 | 53 | self._classes = ( 54 | f'{self._classes} {kwargs.pop("class_")}'.strip() 55 | if "class_" in kwargs 56 | else self._classes 57 | ) 58 | self._id = kwargs.pop("id", self._id) 59 | self._attributes.update( 60 | {key.replace("_", "-"): value for key, value in kwargs.items()} 61 | ) 62 | 63 | @staticmethod 64 | def __get_id_classes_from_selector(selector: str) -> Tuple[str, str]: 65 | selector_pattern = r"^(?:#([^\s\.#]+))?(?:\.([^\s#]+))?" # id.class1.class2 66 | 67 | results = re.findall(selector_pattern, selector)[0] 68 | if results: 69 | id, classes = results 70 | return id, f'{classes.replace(".", " ")}'.strip() 71 | else: 72 | return "", "" 73 | 74 | @staticmethod 75 | def __get_id_classes_attrs_from_tuple( 76 | tuple_: Tuple[str, ...], 77 | ) -> Tuple[str, str, dict]: 78 | attrs = {tuple_[i - 1]: tuple_[i] for i in range(1, len(tuple_), 2)} 79 | 80 | id = attrs.pop("id", "") 81 | classes = f'{attrs.pop("class")}'.strip() if "class" in attrs else "" 82 | 83 | return id, classes, attrs 84 | 85 | def __eq__(self, __value: object) -> bool: 86 | return ( 87 | isinstance(__value, Element) 88 | and self._components == __value._components 89 | and self._attributes == __value._attributes 90 | and self._classes == __value._classes 91 | and self._id == __value._id 92 | ) 93 | 94 | def __class_getitem__( 95 | cls, comps: Union["Component", Iterable["Component"], Tuple[Any, ...]] 96 | ) -> "Element": 97 | return cls()[comps] 98 | 99 | def __getitem__( 100 | self, comps: Union["Component", Iterable["Component"], Tuple[Any, ...]] 101 | ) -> "Element": 102 | if isinstance(comps, Component.__args__): 103 | self._components = (comps,) 104 | else: 105 | 106 | def as_tuple(x): 107 | return ( 108 | (x,) 109 | if isinstance(x, str) or not isinstance(x, Iterable) 110 | else tuple(x) 111 | ) 112 | 113 | comps_tuples = (as_tuple(comp) for comp in comps) 114 | self._components = reduce(lambda l1, l2: l1 + l2, comps_tuples, ()) 115 | 116 | return self 117 | 118 | def __call__(self, *args, **kwargs) -> 'Element': 119 | ret = self.__class__() 120 | updated_element = self.__class__(*args, **kwargs) 121 | ret._components = self._components 122 | ret._id = updated_element._id if updated_element._id else self._id 123 | ret._classes = updated_element._classes if updated_element._classes else self._classes 124 | ret._attributes = dict(**self._attributes) 125 | ret._attributes.update(updated_element._attributes) 126 | 127 | return ret 128 | 129 | def render(self, indent: int = 2) -> str: 130 | nl = "\n" 131 | indented = indent > 0 132 | 133 | def render_var(value, quote_str=False) -> str: 134 | if isinstance(value, (Element, Css)): 135 | return value.render(indent=indent).replace(nl, f'{nl}{" " * indent}') 136 | elif isinstance(value, Var): 137 | return render_var(value.value, quote_str=quote_str) 138 | elif isinstance(value, str): 139 | return ( 140 | f"'{value}'" 141 | if '"' in value 142 | else f'"{value}"' 143 | if quote_str 144 | else value 145 | ) 146 | elif isinstance(value, Iterable): 147 | return f'{nl * indented}{" " * indent}'.join((render_var(i) for i in value)) 148 | else: 149 | return str(value) 150 | 151 | comp_str = nl * indented 152 | for comp in self._components: 153 | if isinstance(comp, str): 154 | _comp = comp.replace("\n", "
") 155 | comp_str += f'{" " * indent}{_comp}{nl * indented}' 156 | elif isinstance(comp, Var): 157 | comp_str += f'{" " * indent}{render_var(comp)}{nl * indented}' 158 | else: 159 | _comp_str = comp.render(indent).replace( 160 | nl, f'{nl * indented}{" " * indent}' 161 | ) 162 | comp_str += f'{" " * indent}{_comp_str}{nl * indented}' 163 | 164 | name = self.__class__.__name__ 165 | 166 | attrs_str = f" id={render_var(self._id, True)}" if self._id else "" 167 | attrs_str += ( 168 | f" class={render_var(self._classes, True)}" if self._classes else "" 169 | ) 170 | 171 | for attr, val in self._attributes.items(): 172 | attrs_str += ( 173 | f" {attr}" 174 | if isinstance(val, bool) 175 | else f" {attr}={render_var(val, True)}" 176 | ) 177 | 178 | return f"<{name}{attrs_str}>{comp_str}" 179 | 180 | def get_vars(self) -> Set[str]: 181 | ret = set() 182 | if isinstance(self._id, Var): 183 | ret.add(self._id.name) 184 | 185 | if isinstance(self._classes, Var): 186 | ret.add(self._classes.name) 187 | 188 | ret.update( 189 | {val.name for val in self._attributes.values() if isinstance(val, Var)} 190 | ) 191 | 192 | def get_var(comp): 193 | if isinstance(comp, (Element, Css)): 194 | return comp.get_vars() 195 | elif isinstance(comp, Var): 196 | if isinstance(comp.value, Var): 197 | return {comp.name}.union(get_var(comp.value)) 198 | elif isinstance(comp.value, (Element, Css)): 199 | return {comp.name}.union(comp.value.get_vars()) 200 | else: 201 | return {comp.name} 202 | else: 203 | return set() 204 | 205 | return ret.union( 206 | reduce(lambda res, comp: res.union(get_var(comp)), self._components, set()) 207 | ) 208 | 209 | def set_vars(self, values_: Dict[str, Any] = {}, **kwargs) -> "Component": 210 | combined_values = {k: v for k, v in chain(values_.items(), kwargs.items())} 211 | 212 | def set_var(comp: Component, values: Dict[str, Any]) -> Component: 213 | if isinstance(comp, (Element, Css)): 214 | return comp.set_vars(values) 215 | elif isinstance(comp, Var): 216 | new_var = comp.set_value(values) 217 | if new_var == comp and isinstance(new_var.value, (Element, Css)): 218 | return Var(new_var.name, new_var.value.set_vars(values)) 219 | else: 220 | return new_var 221 | else: 222 | return comp 223 | 224 | id = set_var(self._id, combined_values) 225 | classes = set_var(self._classes, combined_values) 226 | attributes = { 227 | key: set_var(value, combined_values) 228 | for key, value in self._attributes.items() 229 | } 230 | components = tuple(set_var(comp, combined_values) for comp in self._components) 231 | 232 | if ( 233 | id == self._id 234 | and classes == self._classes 235 | and attributes == self._attributes 236 | and components == self._components 237 | ): 238 | return self 239 | 240 | else: 241 | return self.__class__(id=id, class_=classes, **attributes)[components] 242 | 243 | def __str__(self) -> str: 244 | return self.render(0) 245 | 246 | def __repr__(self) -> str: 247 | return self.__str__() 248 | 249 | 250 | Component = Union[str, Element, Css, Var] 251 | -------------------------------------------------------------------------------- /chope/functions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanstjua/chope/0f9197ce89a1057b565b2da30961b96e5a3bda00/chope/functions/__init__.py -------------------------------------------------------------------------------- /chope/functions/color.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from chope.functions.function import Function 3 | 4 | 5 | class rgb(Function): 6 | def __init__(self, red: int, green: int, blue: int, 7 | alpha: Optional[Union[int, str]] = None): 8 | if isinstance(alpha, str) and '%' not in alpha: 9 | raise ValueError('alpha must be an integer or a percentage.') 10 | 11 | if alpha is not None: 12 | super().__init__(red, green, blue, alpha) 13 | else: 14 | super().__init__(red, green, blue) 15 | 16 | 17 | class hsl(Function): 18 | def __init__(self, hue: Union[int, str], saturation: str, lightness: str, 19 | alpha: Optional[Union[int, str]] = None): 20 | if isinstance(hue, str) and \ 21 | 'deg' not in hue and \ 22 | 'rad' not in hue and \ 23 | 'grad' not in hue and \ 24 | 'turn' not in hue: 25 | raise ValueError('hue must be an integer or an angle.') 26 | 27 | if '%' not in saturation: 28 | raise ValueError('saturation must be a percentage.') 29 | 30 | if '%' not in lightness: 31 | raise ValueError('lightness must be a percentage.') 32 | 33 | if isinstance(alpha, str) and \ 34 | '%' not in alpha: 35 | raise ValueError('alpha must be an integer or a percentage.') 36 | 37 | if alpha is not None: 38 | super().__init__(hue, saturation, lightness, alpha) 39 | else: 40 | super().__init__(hue, saturation, lightness) 41 | 42 | 43 | class hwb(Function): 44 | def __init__(self, hue: Union[int, str], whiteness: str, blackness: str, 45 | alpha: Optional[Union[int, str]] = None): 46 | if isinstance(hue, str) and \ 47 | 'deg' not in hue and \ 48 | 'rad' not in hue and \ 49 | 'grad' not in hue and \ 50 | 'turn' not in hue: 51 | raise ValueError('hue must be an integer or an angle.') 52 | 53 | if '%' not in whiteness: 54 | raise ValueError('whiteness must be a percentage.') 55 | 56 | if '%' not in blackness: 57 | raise ValueError('blackness must be a percentage.') 58 | 59 | if isinstance(alpha, str) and \ 60 | '%' not in alpha: 61 | raise ValueError('alpha must be an integer or a percentage.') 62 | 63 | if alpha is not None: 64 | super().__init__(hue, whiteness, blackness, alpha) 65 | else: 66 | super().__init__(hue, whiteness, blackness) 67 | -------------------------------------------------------------------------------- /chope/functions/function.py: -------------------------------------------------------------------------------- 1 | class Function: 2 | def __init__(self, *args): 3 | self.__args = args 4 | 5 | def render(self) -> str: 6 | name = self.__class__.__name__ 7 | 8 | args = ', '.join(map(str, self.__args)) 9 | 10 | return f'{name}({args})' 11 | -------------------------------------------------------------------------------- /chope/functions/shape.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from chope.functions.function import Function 3 | 4 | 5 | class circle(Function): 6 | def __init__(self, length: str, position_1: Optional[str] = None, 7 | position_2: Optional[str] = None): 8 | if (position_1 is not None and position_2 is None) or \ 9 | (position_1 is None and position_2 is not None): 10 | raise ValueError( 11 | 'position_1 and position_2 must be either both empty or have values.') 12 | 13 | if position_1 is None: 14 | super().__init__(length) 15 | else: 16 | super().__init__(length, 'at', position_1, position_2) 17 | 18 | 19 | class ellipse(Function): 20 | def __init__(self, x_radius: str, y_radius: str, position_1: Optional[str] = None, 21 | position_2: Optional[str] = None): 22 | if (position_1 is not None and position_2 is None) or \ 23 | (position_1 is None and position_2 is not None): 24 | raise ValueError( 25 | 'position_1 and position_2 must be either both empty or have values.') 26 | 27 | if position_1 is None: 28 | super().__init__(x_radius, y_radius) 29 | else: 30 | super().__init__(x_radius, y_radius, 'at', position_1, position_2) 31 | 32 | 33 | class polygon(Function): 34 | def __init__(self, *args): 35 | func_args = [] 36 | if isinstance(args[0], str): 37 | if args[0] == 'nonzero' or args[0] == 'evenodd': 38 | func_args += args[0] 39 | args = args[1:] 40 | else: 41 | raise ValueError('fill-rule can only "nonzero" or "evenodd".') 42 | 43 | try: 44 | for x, y in args: 45 | func_args += 'x y' 46 | except TypeError: 47 | raise ValueError('argument for polygon must be a pair of values') 48 | 49 | super().__init__(*func_args) 50 | 51 | 52 | 53 | class path(Function): 54 | def __init__(self, *args): 55 | if len(args) > 1: 56 | if not (args[0] == 'nonzero' or args[0] == 'evenodd'): 57 | raise ValueError('fill-rule can only "nonzero" or "evenodd".') 58 | 59 | if not isinstance(args[-1], str): 60 | raise ValueError('argument for path must be an SVG string.') 61 | 62 | 63 | super().__init__(*args) 64 | -------------------------------------------------------------------------------- /chope/functions/transform.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from chope.functions.function import Function 3 | 4 | 5 | class transformX(Function): 6 | def __init__(self, length: Union[int, str]): 7 | super().__init__(length) 8 | 9 | 10 | class transformY(Function): 11 | def __init__(self, length: Union[int, str]): 12 | super().__init__(length) 13 | 14 | 15 | class transformZ(Function): 16 | def __init__(self, length: Union[int, str]): 17 | super().__init__(length) 18 | 19 | 20 | class translate(Function): 21 | def __init__(self, length_1: Union[int, str], 22 | length_2: Optional[Union[int, str]] = None): 23 | if length_2 is not None: 24 | super().__init__(length_1, length_2) 25 | else: 26 | super().__init__(length_1) 27 | 28 | 29 | class translate3d(Function): 30 | def __init__(self, tx: Union[int, str], ty: Optional[Union[int, str]] = None, 31 | tz: Optional[Union[int, str]] = None): 32 | if (ty is None and tz is not None) or (tz is None and ty is not None): 33 | raise ValueError( 34 | 'ty and tz must either be both None or both with values.') 35 | 36 | if '%' in tz: 37 | raise ValueError('tz cannot be percentage.') 38 | 39 | if ty is None and tz is None: 40 | super().__init__(tx) 41 | else: 42 | super().__init__(tx, ty, tz) 43 | -------------------------------------------------------------------------------- /chope/variable.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | 4 | class Var: 5 | def __init__(self, name: str, value: Any = None) -> None: 6 | self._name = name 7 | self._value = value 8 | 9 | def __eq__(self, __value: object) -> bool: 10 | return ( 11 | isinstance(__value, Var) 12 | and self._name == __value._name 13 | and self._value == __value._value 14 | ) 15 | 16 | @property 17 | def name(self) -> str: 18 | return self._name 19 | 20 | @property 21 | def value(self) -> Any: 22 | return self._value if self._value is not None else f'[{self.name} is not set]' 23 | 24 | def set_value(self, values: Dict[str, Any]) -> "Var": 25 | if self._name in values: 26 | return Var(self._name, values[self._name]) 27 | else: 28 | new_value = ( 29 | self._value.set_value(values) 30 | if isinstance(self._value, Var) 31 | else self._value 32 | ) 33 | if new_value != self._value: 34 | return Var(self._name, new_value) 35 | else: 36 | return self 37 | 38 | def __str__(self) -> str: 39 | return f'Var["{self.name}","{self.value}"]' 40 | 41 | def __repr__(self) -> str: 42 | return self.__str__() 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "chope" 7 | version = "0.5.2" 8 | authors = [ 9 | {name = "Johan Tjuatja", email = "hanstjua@yahoo.co.id"}, 10 | ] 11 | description = "CSS & HTML on Python Easily" 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3.7", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ] 23 | dependencies = ["pytest"] 24 | 25 | [project.urls] 26 | "Homepage" = "https://github.com/hanstjua/chope" 27 | "Bug Tracker" = "https://github.com/hanstjua/chope/issues" 28 | 29 | [tool.setuptools] 30 | packages = ["chope"] 31 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanstjua/chope/0f9197ce89a1057b565b2da30961b96e5a3bda00/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_css.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chope.css import Css, RenderError, in_, percent, px, rem 4 | from chope.variable import Var 5 | 6 | expected = '''h1 { 7 | color: red; 8 | font-size: 1.2rem; 9 | padding: 1in; 10 | margin: 2%; 11 | outline: 1px dotted green; 12 | } 13 | 14 | .my-class { 15 | background: black; 16 | }''' 17 | 18 | 19 | def test_should_render_css_correctly(): 20 | style = Css[ 21 | 'h1': dict( 22 | color='red', 23 | font_size=rem/1.2, 24 | padding=in_/1, 25 | margin=percent/2, 26 | outline=(px/1, 'dotted', 'green') 27 | ), 28 | '.my-class': dict( 29 | background='black' 30 | ) 31 | ] 32 | 33 | assert style.render(3) == expected 34 | 35 | def test_when_indent_is_zero_should_render_flat_string(): 36 | expected = 'a {b: c;}d {e: f;}' 37 | 38 | style = Css['a': dict(b='c'), 'd': dict(e='f')] 39 | 40 | assert style.render(0) == expected 41 | 42 | def test_set_variable_values(): 43 | expected_render = 'h1 {color: red;padding: 1in;size: 5px;margin: 1%;}.my-class {background: black;}' 44 | expected_css = Css[ 45 | 'h1': dict( 46 | color=Var('color', 'red'), 47 | padding=Var('padding', in_/1), 48 | size=Var('size', Var('size_nested', px/5)), 49 | margin=Var('margin', percent/1) 50 | ), 51 | '.my-class': Var('my-class', {'background': 'black'}) 52 | ] 53 | 54 | css = Css[ 55 | # declaration variables 56 | 'h1': dict( 57 | color=Var('color'), 58 | padding=Var('padding', in_/1), 59 | 60 | # nested variables 61 | size=Var('size', Var('size_nested', px/10)), 62 | margin=Var('margin', Var('margin_nested', percent/2)) 63 | ), 64 | 65 | # rule variable 66 | '.my-class': Var('my-class') 67 | ] 68 | 69 | values = { 70 | 'color': 'blue', 71 | 'size_nested': px/50, 72 | 'margin': percent/1, 73 | 'my-class': {'background': 'black'} 74 | } 75 | 76 | new_css = css.set_vars(values, color='red', size_nested=px/5) 77 | 78 | assert new_css == expected_css 79 | assert new_css.render(indent=0) == expected_render 80 | 81 | def test_handle_unset_rule_variable(): 82 | css = Css[ 83 | '.my-class': Var('my-class') 84 | ] 85 | 86 | with pytest.raises(RenderError): 87 | css.render() 88 | 89 | def test_get_variable_names(): 90 | expected = {'color', 'size', 'size_nested', 'my-class'} 91 | 92 | css = Css[ 93 | 'h1': dict( 94 | color=Var('color'), 95 | size=Var('size', Var('size_nested', px/10)), 96 | ), 97 | '.my-class': Var('my-class') 98 | ] 99 | 100 | assert css.get_vars() == expected 101 | 102 | @pytest.mark.parametrize( 103 | 'input1, input2, expected', 104 | ((Css['h1': {'asdf': 123}], Css['h1': {'asdf': 123}], True), 105 | (Css['h1': {'asdf': 123}], Css['h1': {'asdf': 234}], False), 106 | (Css['h1': {'asdf': 123}], 'some_str', False)) 107 | ) 108 | def test_comparison(input1, input2, expected): 109 | assert (input1 == input2) == expected 110 | -------------------------------------------------------------------------------- /tests/test_element.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | 5 | from chope import Element 6 | from chope.css import Css 7 | from chope.element import DuplicateAttributeError 8 | from chope.variable import Var 9 | 10 | 11 | class a(Element): 12 | pass 13 | 14 | 15 | class b(Element): 16 | pass 17 | 18 | 19 | def test_should_render_nested_components_correctly(): 20 | class e1(Element): 21 | pass 22 | 23 | class e2(Element): 24 | pass 25 | 26 | class e3(Element): 27 | pass 28 | 29 | expected = """ 30 | text 31 | 32 | word
33 |
34 | 35 | letter 36 | space 37 | symbols 38 | 39 |
""" 40 | 41 | component = e1( 42 | class_="my-class", color="yellow", size=123, my_attr="yes", autofocus=True 43 | )["text", e2["word\n"], e3["letter", ["space", "symbols"]]] 44 | 45 | assert component.render(4) == expected 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "selectors, tuple, kv, expected", 50 | ( 51 | ("#id.class1.class2", (), {}, 'id="id" class="class1 class2"'), 52 | ("", ("my[attr]", "x"), {}, 'my[attr]="x"'), 53 | ("", (), {"named": 1.0}, "named=1.0"), 54 | ("#id", ("my-attr", "x"), {}, 'id="id" my-attr="x"'), 55 | ("", ("my@attr", 1), {"named": 1.0}, "my@attr=1 named=1.0"), 56 | ("#id", (), {"named": 1.0}, 'id="id" named=1.0'), 57 | ("#id", ("my.attr", 1), {"named": 1.0}, 'id="id" my.attr=1 named=1.0'), 58 | ), 59 | ids=[ 60 | "selectors only", 61 | "tuple only", 62 | "key-value only", 63 | "selector + tuple", 64 | "tuple + key-value", 65 | "selector + key-value", 66 | "all", 67 | ], 68 | ) 69 | def test_attribute_definitions_styles( 70 | selectors: str, tuple: Tuple[str, ...], kv: dict, expected: str 71 | ): 72 | args = (selectors,) + tuple if selectors else tuple 73 | assert a(*args, **kv).render(0) == f"
" 74 | 75 | 76 | @pytest.mark.parametrize( 77 | "selector, tuple, kv", 78 | ( 79 | ("#id", ("id", "di"), {}), 80 | ("#id", (), {"id": "di"}), 81 | ("", ("id", "id"), {"id": "di"}), 82 | ("#id", ("id", "di"), {"id": "di"}), 83 | ), 84 | ids=["selector + tuple", "selector + key-value", "tuple + key-value", "all"], 85 | ) 86 | def test_conflicting_attribute_definitions_should_raise_error( 87 | selector: str, tuple: Tuple[str, ...], kv: dict 88 | ): 89 | with pytest.raises(DuplicateAttributeError): 90 | args = (selector,) + tuple if selector else tuple 91 | a(*args, **kv).render(0) 92 | 93 | 94 | def test_zero_indent_should_render_flat_string(): 95 | expected = "text" 96 | 97 | assert a["text"].render(0) == expected 98 | 99 | 100 | def test_when_negative_number_is_passed_to_render_should_render_with_zero_indent(): 101 | assert a["text"].render(-1) == a["text"].render(0) 102 | 103 | 104 | def test_able_to_render_css(): 105 | expected = "\n b {\n prop: text;\n }\n" 106 | 107 | comp = a[Css["b" : dict(prop="text")]] 108 | 109 | assert comp.render(2) == expected 110 | 111 | 112 | def test_infer_id_and_classes_through_css_selector(): 113 | expected = 'text' 114 | 115 | assert a("#id.class1.class2", class_="class3 class4")["text"].render(0) == expected 116 | 117 | 118 | def test_should_raise_exception_if_id_detected_in_both_kwargs_and_css_selector(): 119 | with pytest.raises(DuplicateAttributeError): 120 | a("#a", id="a") 121 | 122 | 123 | def test_override_element_attributes(): 124 | expected_comp = a(name='content', id='overriden', some_attr='new')['Content'] 125 | 126 | comp = a(name='content', id='original', some_attr='old')['Content'] 127 | 128 | assert comp(id='overriden', some_attr='new').render() == expected_comp.render() 129 | 130 | 131 | def test_set_variable_values(): 132 | expected_render = 'OuterInnerset_nestedset_nestedset_nested' 133 | expected_comp = a(id=Var("id", "id"), count=Var("count", 1))[ 134 | Var("outer", "Outer"), 135 | a(name=Var("name", "default"))[ 136 | Var("inner", b(name="inner")[ 137 | "Inner" 138 | ]) 139 | ], 140 | Var("unset_nested", Var("set_nested", a["set_nested"])), 141 | Var("unset_nested", a[Var("set_nested", a["set_nested"])]), 142 | Var("set_nested", a["set_nested"]), 143 | ] 144 | 145 | comp = a(id=Var("id"), count=Var("count"))[ 146 | # variable in element 147 | Var("outer"), 148 | 149 | # variable in inner element 150 | a(name=Var("name", "default"))[ 151 | Var("inner") 152 | ], 153 | 154 | # nested variables 155 | Var("unset_nested", Var("set_nested")), 156 | 157 | # nested variable inside element 158 | Var("unset_nested", a[Var("set_nested")]), 159 | Var("set_nested", a[Var("unset_nested")]), 160 | ] 161 | 162 | values = { 163 | "id": "id", 164 | "count": 1, 165 | "outer": "Outer", 166 | "inner": b(name="inner")["Inner"], 167 | "set_nested": a["set_nested"], 168 | } 169 | 170 | new_comp = comp.set_vars(values) 171 | 172 | assert new_comp == expected_comp 173 | assert new_comp.render(indent=0) == expected_render 174 | 175 | 176 | def test_set_variable_values_using_kwargs(): 177 | expected_comp = a(name=Var("name", "my-name"))[ 178 | Var("content", "My content."), 179 | b[Var("inner", "Inner content.")] 180 | ] 181 | 182 | comp = a(name=Var("name"))[ 183 | Var("content"), 184 | b[Var("inner")] 185 | ] 186 | 187 | new_comp = comp.set_vars( 188 | {"name": "not-my-name"}, 189 | name="my-name", 190 | content="My content.", 191 | inner="Inner content.", 192 | ) 193 | 194 | assert new_comp == expected_comp 195 | 196 | 197 | def test_set_variables_to_iterables(): 198 | equivalent_comp = a[ 199 | b['0'], 200 | b['1'], 201 | b['2'], 202 | b['3'], 203 | b['4'] 204 | ] 205 | 206 | expected = equivalent_comp.render() 207 | 208 | comp = a[ 209 | Var('content'), 210 | (b['3'], b['4']) 211 | ] 212 | 213 | result = comp.set_vars(content=(b[str(i)] for i in range(3))).render() 214 | 215 | assert result == expected 216 | 217 | 218 | def test_get_variable_names(): 219 | vars_count = 5 220 | vars = [Var(str(i)) for i in range(vars_count)] 221 | 222 | comp = a(var=vars[0])[ 223 | vars[1], 224 | a(var=vars[1])[ 225 | vars[2], 226 | # nested variables 227 | Var("0", vars[3]), 228 | Var("0", a[vars[4]]), 229 | ], 230 | ] 231 | 232 | expected = {str(i) for i in range(vars_count)} 233 | 234 | assert comp.get_vars() == expected 235 | 236 | def test_able_to_accept_empty_iterable_as_a_child(): 237 | comp = a[ 238 | b[[]], 239 | b[()], 240 | b[(i for i in range(0))] 241 | ] 242 | expected = '' 243 | 244 | assert comp.render(0) == expected 245 | -------------------------------------------------------------------------------- /tests/test_function.py: -------------------------------------------------------------------------------- 1 | from chope.css import px 2 | from chope.functions.function import Function 3 | 4 | 5 | def test_should_render_correctly(): 6 | class test_func(Function): 7 | def __init__(self, arg_1: str, arg_2: int, arg_3: float): 8 | super().__init__(arg_1, arg_2, arg_3) 9 | 10 | assert test_func(px/1, 2, 3.0).render() == 'test_func(1px, 2, 3.0)' 11 | --------------------------------------------------------------------------------